Compare commits

...

300 commits

Author SHA1 Message Date
Logan Yang
632c1e81b3
docs: promote Copilot v4 in the README (#2544)
* docs: promote Copilot v4 in the README

Add a "Copilot v4: Agent Mode, Reimagined" callout (with a Table of Contents
entry) introducing the v4 agent revamp — run opencode, Claude Code, or Codex
natively inside your vault — linking to the v4 promo page. Rebased onto
current master. Refs logancyang/obsidian-copilot-preview#112.

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

* docs: drop the v3 callout and add a Supporter CTA to the v4 section

Remove the "Copilot V3 is a New Era" section (and its ToC entry) now that v4
is the headline, and add "Join Supporter to experience the magic of Copilot
v4 now!" to the v4 promo.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:05:59 -07:00
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
Zero Liu
cbe2ed771d
chore(eslint): enable no-restricted-imports; replace moment with luxon (#2422)
Removes the `no-restricted-imports: off` override so the obsidianmd recommended
rule applies (bans axios/node-fetch/moment/etc.). Replaces the two `moment(...)`
call sites in `src/utils.ts` with `luxon`'s `DateTime` (already a dep) and adds
unit tests covering local/UTC formatting, zero-padding, immutability, and the
invalid-input fallback. Integration tests get a per-file override since they
need `node-fetch` to polyfill jsdom fetch. `no-restricted-globals` stays
disabled — banning the global `app` will land in a separate PR.
2026-05-13 02:13:27 -07:00
Zero Liu
eca1b081d5
chore(eslint): enable depend/ban-dependencies and fix violations (#2421)
Removes the "off" overrides for depend/ban-dependencies, configures the
package.json check with an allowed-list for deps we deliberately keep
(crypto-js, lodash.debounce, eslint-plugin-react, lint-staged, npm-run-all),
and drops three unused direct deps: axios, builtin-modules, and node-fetch
(plus @types/node-fetch). The integration-test fetch polyfills are no longer
needed in Node 18+ jsdom.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:07:28 -07:00
Zero Liu
9dba5ff02b
chore(lint): enable import/no-nodejs-modules and import/no-extraneous-dependencies (#2420)
Removes the deferred "off" overrides from eslint.config.mjs so the
obsidianmd recommended config's "error" severity applies. Scopes
import/no-nodejs-modules off for test setup, mocks, and Node-context
build scripts (e.g. jest.setup.js polyfills TextEncoder/TextDecoder
from node:util). import/no-extraneous-dependencies is now enabled
everywhere; declared js-yaml, dotenv, and @jest/globals as explicit
devDependencies so transitive uses pass.

Fixes the one real violation in src/utils.ts by migrating two moment
call sites (formatDateTime, stringToFormattedDateTime) to Luxon, which
is already in dependencies. Adds 10 unit tests covering UTC/local
formatting, zero-padding, round-tripping, and invalid-input fallback.
2026-05-13 02:01:32 -07:00
Zero Liu
2df1be0b4f
chore(lint): enable no-unsanitized/method and property (#2418)
Remove the explicit "off" overrides in eslint.config.mjs so the rules
from obsidianmd.configs.recommended apply. Add a scoped disable for the
sole violation in scripts/printPromptDebug.js, where the dynamic import
targets a controlled path under os.tmpdir() produced by esbuild.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:56:00 -07:00
Zero Liu
f7bcac3d86
chore(eslint): annotate disabled type-aware rules with violation counts (#2417)
Group the 14 disabled @typescript-eslint rules into Heavy / Medium / Quick-win
buckets with inline violation counts measured against src/**/*.{ts,tsx}. Makes
it obvious which rules (e.g. restrict-template-expressions at 1, no-base-to-string
at 7) are cheap follow-ups vs. the no-unsafe-* family that dominates the block.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:52:09 -07:00
Zero Liu
a529387533
chore(lint): enable @microsoft/sdl/no-inner-html and fix violations (#2419)
Drops the `off` override so the rule (set to `error` by obsidianmd's
recommended config) is enforced. Fixes the one production violation in
markdown-preview by using `replaceChildren()` instead of clearing via
`innerHTML`. Rewrites test fixtures to use `DOMParser` and mock impls
to use `textContent` so the rule is on for tests too.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:43:25 -07:00
Zero Liu
08c14440dc
chore(lint): enable obsidianmd/rule-custom-message and fix violations (#2416)
* chore(lint): enable obsidianmd/rule-custom-message and fix violations

Turn on the obsidianmd/rule-custom-message ESLint rule (which wraps no-console
to enforce logInfo/logWarn/logError over console.log per AGENTS.md). Swap
console.log → logInfo across LLMProviders/ and search/, and delete console.log
noise from test files.

src/logger.ts, src/chainFactory.ts (circular import with constants), and
scripts/** are exempted via narrow file overrides.

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

* chore(lint): enable obsidianmd/no-unsupported-api (#2414)

Removes the deferred "off" override so the rule runs at the recommended
severity on .ts/.tsx. With manifest.minAppVersion=1.4.0 and obsidian@1.2.5
(no @since tags) the rule fires on nothing today — it acts as a guard for
future obsidian type-stub bumps.

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

* chore(lint): enable obsidianmd/object-assign rule (#2415)

Follow-up to #2410. The rule only flags `Object.assign(<ident-containing-default>, <non-object-literal>)` — the Obsidian anti-pattern of mutating DEFAULT_SETTINGS. No call sites in this repo match, so enabling produces zero new errors.

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

* refactor: extract ChainType into src/chainType.ts to break import cycle

constants.ts → chainFactory.ts → logger.ts → settings/model.ts → constants.ts
formed a runtime cycle because chainFactory.ts (a heavy LangChain module) re-
exported the ChainType enum that constants.ts needed at module load. Move the
enum into a tiny standalone file, update all 26 importers, and restore
logInfo in chainFactory.ts. The eslint override for chainFactory.ts is no
longer needed.

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 01:39:35 -07:00
Zero Liu
f767d88038
chore(lint): enable obsidianmd/object-assign rule (#2415)
Follow-up to #2410. The rule only flags `Object.assign(<ident-containing-default>, <non-object-literal>)` — the Obsidian anti-pattern of mutating DEFAULT_SETTINGS. No call sites in this repo match, so enabling produces zero new errors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:26:48 -07:00
Zero Liu
c88b1e2e5b
chore(lint): enable obsidianmd/no-unsupported-api (#2414)
Removes the deferred "off" override so the rule runs at the recommended
severity on .ts/.tsx. With manifest.minAppVersion=1.4.0 and obsidian@1.2.5
(no @since tags) the rule fires on nothing today — it acts as a guard for
future obsidian type-stub bumps.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:21:25 -07:00
Zero Liu
710acc2436
chore(lint): enable obsidianmd/no-global-this and fix violations (#2413)
Removes the deferred `"off"` override and rewrites the 55 violations
(all in test/mocks/Node-script files) to use `window.*` instead of
`global.*`. Production code was already cleaned up in #2401.

- 18 test files + __mocks__/obsidian.js + jest.setup.js: mechanical
  `global.X` → `window.X` (Jest runs under jsdom, so window === globalThis)
- scripts/printPromptDebugEntry.ts: inline eslint-disable — Node-only
  debug script with no window available

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:14:47 -07:00
Zero Liu
48794ce097
chore(eslint): enable obsidianmd/prefer-active-doc (#2411)
Flip the rule from "off" to "error" to catch accidental references to
the global `document` (which always points at the main window even when
the user is interacting with a popout).

The only flagged site was a `document` field on the `RelevantNoteEntry`
type — not a DOM reference, but the rule pattern-matches the identifier.
Rename it to `note` so `entry.note.path/title` reads naturally next to
`entry.metadata.*`, and remove the suppression.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:09:06 -07:00
Zero Liu
99b3b1e22a
chore(lint): enable obsidianmd/vault/iterate and fix violations (#2412)
Replace `app.vault.getFiles().find(f => f.path === id)` lookups in the
Project Context Manage modal with `getAbstractFileByPath` + `instanceof
TFile` narrowing — direct hash lookup vs full-vault scan.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:05:38 -07:00
Zero Liu
17739cafa0
Enable basic obsidian eslint rules (#2410) 2026-05-13 00:49:49 -07:00
Logan Yang
bb013ef4bb
docs: add commit-signing setup; consolidate README ToC (#2409)
- CONTRIBUTING.md: new "Commit Signing" section with the 4-step SSH
  signing setup so future protection rules requiring signed commits
  can be turned on with team-ready instructions in place.
- README.md: remove the abbreviated ToC, promote the comprehensive
  one to the top, drop the self-referencing entry.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:19:00 -07:00
Zero Liu
15ac5844ed
fix(vault): respect user trash preference via FileManager.trashFile (W7/9) (#2405)
* fix(vault): respect user trash preference via FileManager.trashFile (W7/9)

Eighth of nine workspaces splitting #2397. Intentional behavior change:
file deletions now honor the user's Settings -> Files and links ->
Deleted files preference (system trash / .trash / permanent), instead
of unconditionally calling vault.delete(file, true).

- src/utils/vaultAdapterUtils.ts: add trashFile(app, file) helper that
  casts past the missing app.fileManager.trashFile typedef.
  createSyntheticTFile now uses Object.create(TFile.prototype) so
  instanceof TFile returns true.
- src/projects/ProjectFileManager.ts: constructor takes App instead
  of Vault; all vault.delete(file, true) -> trashFile(app, file).
  Project create rollback now trashes partial files (was permanent-
  delete before).
- src/projects/projectRegister.ts: constructor takes App.
- src/projects/projectMigration.ts: rollbackCreatedFile(app, ...),
  ensureProjectsMigratedIfNeeded(app), all delete sites use trashFile.
- src/main.ts: deleteChatHistory uses trashFile; ProjectRegister
  constructed with this.app.
- src/system-prompts/systemPromptManager.ts: prompt delete via trashFile.
- src/commands/customCommandManager.ts: command delete via trashFile.
- src/LLMProviders/projectManager.ts, src/components/Chat.tsx,
  src/components/chat-components/ProjectList.tsx: pass app (not vault)
  to ProjectFileManager.getInstance.
- __mocks__/obsidian.js: app.fileManager.trashFile jest mock.
- Tests updated to expect trashFile + App constructor arg.

W0, W1, and #2402 already merged. Behavior change requires manual
verification (test plan section 1 + 6).

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

* chore(manifest): bump minAppVersion to 1.4.0

FileManager.trashFile (used by the vault deletion helper introduced in
W7/9) requires Obsidian 1.4+. Bumping minAppVersion ensures the plugin
store only offers this and future releases to compatible app versions;
users on older Obsidian builds stay on their current Copilot version
instead of receiving an update that would crash on delete.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:49 -07:00
Zero Liu
97165937bb
chore(providers): adopt non-deprecated LangChain APIs + remaining type cleanup (W6/9) (#2404)
Seventh of nine workspaces splitting #2397. Picks up provider-file
changes W1 deferred — primarily LangChain public-API renames that
aren't pure type changes.

- src/LLMProviders/BedrockChatModel.ts: _getType() -> getType()
  (non-deprecated method, same behavior)
- src/LLMProviders/BedrockChatModel.test.ts: matching mock-message
  rename across all streaming-decode tests
- src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts: stringify
  non-string response.content via JSON.stringify instead of String()
  (avoids [object Object] when content is structured)

No semantic message-handling changes. All behavior preserved.

W0, W1, and #2402 already merged.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:00:56 -07:00
Zero Liu
858ce4c862
chore(styles): replace element.style with Tailwind + CSS vars (W5/9) (#2400)
* chore(styles): replace element.style with Tailwind + CSS vars (W5/9)

Sixth of nine workspaces splitting #2397. Replaces direct DOM
inline-style writes with Tailwind utility classes (addClass) and
CSS custom properties for dynamic values.

- src/components/modals/SourcesModal.tsx — addClass + tw-* classes
- src/components/modals/AddImageModal.tsx — input.style.display -> tw-hidden
- src/components/modals/CachePreviewModal.ts — modalEl.addClass tw-w/tw-max-w
- src/components/modals/project/AddProjectModal.tsx — !tw-max-h-[85vh]
- src/components/modals/project/context-manage-modal.tsx — tw-min-w-[50vw]
- src/components/quick-ask/QuickAskOverlay.tsx — body cursor/select via tw + CSS var
- src/components/ui/textarea.tsx — --copilot-autosize-height + tw-h-[var(...)]
- src/components/chat-components/ChatViewLayout.ts — removeProperty fix
- src/components/command-ui/draggable-modal.tsx — tw-left/top-[var(--copilot-drag-x/y,0px)]
- src/settings/SettingsPage.tsx — tw-select-text
- src/settings/v2/components/ModelEditDialog.tsx — tw-h-4/5
- src/system-prompts/SystemPromptAddModal.tsx — tw-h-4/5
- src/hooks/use-draggable.ts and use-resizable.ts: write
  --copilot-drag-x/y and --copilot-resize-cursor CSS variables instead
  of inline left/top/cursor; consumers reference them via Tailwind
  arbitrary-value classes

Behavior: visual-only changes; no logic changes. Reviewer should
walk through SCORECARD_WARNINGS_TEST_PLAN.md §3 (drag/resize, QuickAsk
positioning, draggable modal, typeahead positioning, inline pills).

W0 already merged.

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

* chore(styles): use Obsidian setCssProps API for CSS variable writes

Follow-up to the W5 commit. The Obsidian scorecard warning is
"Use the setCssProps function if the CSS properties need to change
dynamically", and the prior pass still wrote them via
element.style.setProperty / removeProperty. Routes the remaining 14
sites across 6 files through HTMLElement#setCssProps, which is the
Obsidian-sanctioned API for writing CSS custom properties.

- src/hooks/use-draggable.ts: --copilot-drag-x/y batched into one call
- src/hooks/use-resizable.ts: --copilot-resize-cursor set/clear
- src/components/quick-ask/QuickAskOverlay.tsx: same cursor pattern
- src/components/chat-components/ChatViewLayout.ts: --copilot-status-bar-clearance
- src/components/ui/textarea.tsx: --copilot-autosize-height
- src/utils/dom/dynamicStyleManager.ts: batches every set+clear into a single
  setCssProps call per invocation (CSSOM defines setProperty(name, "") to
  call removeProperty(name), so empty-string entries clear)

The single getPropertyValue read at src/components/CopilotView.tsx:112 is
a read, not a write, and is not flagged by the scorecard rule.

Verification: grep -rn "\.style\.setProperty\|\.style\.removeProperty" src
returns zero hits. npm run lint, npm run build, npm test (1962 tests) all
pass.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 22:50:05 -07:00
Zero Liu
8b01ef746e
Fix broken tests (#2408) 2026-05-12 22:28:12 -07:00
Zero Liu
6331631dd0
chore(popout): window-global API swaps — timers, globalThis, base64 (W3/9) (#2401)
Fourth of nine workspaces splitting #2397. Mechanical swaps to
window-namespaced globals for Obsidian popout window compatibility.
No semantic behavior change beyond "works correctly in a popout."

- setTimeout / clearTimeout / setInterval / clearInterval /
  requestAnimationFrame → window.* across all callsites
- globalThis → window (BedrockChatModel fetch.bind, scattered sites)
- atob / btoa → Buffer.from(..., 'base64') / Buffer.from(...).toString('base64')
  - src/encryptionService.ts (drops Node 'buffer' import; Buffer is global)
  - src/utils.ts safeFetch arrayBuffer() branch
  - src/LLMProviders/BedrockChatModel.ts decodeBase64ToUint8Array

Builds on top of #2398 (W0 — deps bump).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 22:24:17 -07:00
Zero Liu
c67c4fc200
fix(popout): document ownership and selection listener fixes (W4/9) (#2406)
* fix(popout): document ownership and selection listener fixes (W4/9)

Fifth of nine workspaces splitting #2397. Real popout-window
correctness fixes — semantic choices about which window owns a DOM
operation, plus selection-listener captured-document fixes.

- document → activeDocument where a generic active-window lookup is
  correct (SourcesModal, ChatViewLayout, ItemView/Notice mocks,
  TabContext, ChatInput keydown listener, CopilotView keyboard
  observer, TypeaheadMenuPortal, AddImageModal, and ?? document
  fallbacks across draggable-modal, menu-command-modal,
  CustomCommandChatModal, use-draggable, use-resizable, and
  QuickAskOverlay).
- document → root.ownerDocument in ChatSingleMessage's
  linkInlineCitations so DOM nodes end up in the citation's window;
  same pattern in the processMessage streaming block via
  contentRef.current.ownerDocument.
- New getEditorDocument(editor) helper in BasePillNode. createDOM and
  exportDOM now accept the LexicalEditor; pills create DOM in the
  editor's window (correct popout behavior). All 6 pill subclasses
  updated to match the new signature.
- main.ts: selectionListenerDocument captured at listener registration;
  remove uses the same document (fixes leak when activeDocument has
  drifted before unload).
- chatSelectionHighlightController.ts: getActiveViewOfType(MarkdownView)
  replaces the brittle activeLeaf type comparison.
- Test setup: (window as any).activeDocument = window.document in
  ChatSingleMessage.test; window.document.createElement in
  AgentPrompt.test Notice mock.

Behavior fix: chat in a popout window now creates DOM nodes in the
popout's document, not the focused window's document.

W0, W1, and #2402 already merged.

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

* fix(popout): prefer element.doc/.win over ownerDocument and activeDocument

Switch popout-window-sensitive call sites to Obsidian's `.doc` / `.win`
augmentations so DOM ops, listeners, portals, and positioning follow the
element's actual owner window. Recreate Lexical root on view migration to
rebind input handling after a leaf moves between windows. Document the
decision order in AGENTS.md and polyfill `Node.doc` / `Node.win` in
jest.setup.js so tests still run under jsdom.

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

* improve image upload implementation

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:28:04 -07:00
Zero Liu
7619fb5578
chore(promises): explicit handling of floating promises (W2/9) (#2407)
Third of nine workspaces splitting #2397. Surfaces previously
swallowed promise rejections via .catch(logError) or `void` prefix.
No logic changes - only error-handling explicitness.

- ~67 floating promises now use void / .catch(logError)
- Promise-returning callbacks where void was expected are wrapped
- ConfirmModal accepts onConfirm/onCancel returning void | Promise<void>;
  rejections logged via logError
- Custom command runners wrap `result instanceof Promise` paths
- ChatUIState.replaceMessages is now async to match its delegate
- ChainManager initialize / createChainWithNewModel are explicitly
  voided with .catch(logError) at call sites
- dbOperations subscribeToSettingsChange uses void IIFE with try/catch

Behavior change: errors previously hidden by floating promises now
log via @/logger. No code paths changed.

W0, W1, and #2402 already merged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:42:22 -07:00
Zero Liu
e470b5c70c
chore(types): tighten types and replace TFile/TFolder casts (W1/9) (#2403) 2026-05-12 16:18:22 -07:00
Logan Yang
b7ad36624a
perf(deps): route Cohere & Mistral through OpenAI-compat to cut bundle by 1.8MB (#2402)
Replace ChatCohere / ChatMistralAI / CohereEmbeddings with ChatOpenAI /
OpenAIEmbeddings pointed at each provider's OpenAI-compatible endpoint:

- Cohere: https://api.cohere.ai/compatibility/v1
- Mistral: https://api.mistral.ai/v1 (already OpenAI-shaped)

Dropping @langchain/cohere, @langchain/mistralai, cohere-ai, and
@mistralai/mistralai also eliminates the @aws-sdk/* and @smithy/* chain
that cohere-ai dragged in for Bedrock auth (a path this plugin doesn't use).

Production bundle: 5.21MB -> 3.39MB.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:53:59 -07:00
Zero Liu
2fb1552a49
chore(deps): bump deps ahead of scorecard cleanup (W0/9) (#2398)
First of nine workspaces splitting #2397 into focused PRs (see
designdocs/todo/SCORECARD_WARNINGS.md, landing in a later workspace).
This PR is deps-only so the source-touching workspaces (types,
promise safety, popout compat, styles, trash semantics, brevilabs
rewrite, eslint rules) can each land independently.

Dependencies:
- @codemirror/state, @codemirror/view: peer deps for newer @lexical/* usage
- @langchain/classic: required by langchain ^1 split layout
- openai: direct consumer in upcoming provider work
- uuid + @types/uuid: replaces ad-hoc id generation
- zod: schema validation used in upcoming type tightening

DevDependencies:
- eslint-plugin-obsidianmd: installed now but not wired into .eslintrc
  until the final workspace (W9) once all flagged sites are cleaned up

No source changes; no behavior changes. npm run lint / build / test
all pass on this branch.
2026-05-12 14:11:53 -07:00
Logan Yang
8f35b396bc
chore(models): bump built-in models to latest (May 2026) (#2396)
OpenAI:
- gpt-5.4 -> gpt-5.5 (new flagship, released 2026-04-24)
- gpt-5-mini -> gpt-5.4-mini
- gpt-5-nano -> gpt-5.4-nano (enum only)

Anthropic:
- claude-opus-4-6 -> claude-opus-4-7 (Opus 4.6 now legacy)
- Add claude-haiku-4-5 (disabled by default)

Google:
- gemini-3.1-flash-lite-preview -> gemini-3.1-flash-lite (GA 2026-05-07)

xAI:
- grok-4-1-fast -> grok-4.3 (grok-4-1-fast retiring 2026-05-15)

OpenRouter mirrors updated for all of the above.

ModelParametersEditor: gate Extra-High reasoning effort on
startsWith("gpt-5.5") instead of "gpt-5.4" so the option follows
the new flagship and does not accidentally surface on gpt-5.4-mini
or gpt-5.4-nano.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:34:07 -07:00
Zero Liu
358b373953
chore: remove orphaned RemoveFromIndexModal (#2395)
The modal and its REMOVE_FILES_FROM_COPILOT_INDEX command were orphaned in
the vault search v3 migration (#1703): the addCommand registration was
removed but the modal file and its constant entries were left behind.
Nothing in the codebase has instantiated it since. Deleting it also clears
the risk-report finding about contentEl.createEl("style", ...), which
Obsidian disallows at runtime.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:45:15 -07:00
Wenzheng Jiang
38a23358cd
fix(miyo): send portable folder-prefixed path to related-notes endpoint (#2393)
findRelevantNotes and MiyoIndexBackend.getDocumentsByPath were sending the
local absolute filesystem path as Miyo's file_path. On remote-vault setups
the indexed path is the server's absolute path, so the scroll filter
matched zero points and the related-notes endpoint always returned 404.

Replace getMiyoAbsolutePath with getMiyoFilePath, which returns
"<FolderName>/<vaultRelativePath>" — the inverse of
getVaultRelativeMiyoPath. Miyo's resolveFileInput remaps this format to
the registered folder's canonical absolute path, so it works identically
for local and remote setups.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:52:04 +09:00
齐柯宇 (Qi Kery)
e826889c11
fix: use /responses for GitHub Copilot codex models (#2320)
* fix: use /responses for GitHub Copilot codex models
Route Copilot codex models away from /chat/completions to avoid 400
errors, and keep ping/model verification aligned with runtime routing.

* refactor: deduplicate shouldUseGitHubCopilotResponsesApi calls and restore rationale comments

  Cache the result in useCopilotResponses in both createModelInstance() and
  tryPing() instead of calling the helper multiple times per flow.

  Restore Reason comments removed in c96a9d8 that document non-obvious
  constraints: why ChatOpenAICompletions is used over ChatOpenAI, the
  delta.role fallback to avoid broken tool_call_chunks, defaultRole?: any
  type choice, streamUsage/baseURL SDK behavior, and the typeof Request
  guard for Obsidian mobile runtimes.
2026-05-10 17:06:26 -07:00
HT
baa2b954dc
fix: prevent </div> from being consumed by indented code blocks in think sections (#2343)
When thinking content ends with a 4-space-indented line (e.g. Gemma's
bullet-point reasoning), markdown's indented code block rule would consume
the immediately-following </div> closing tag and render it as literal
"&lt;/div&gt;" text. Fix by always trimming section content and appending
\n before the closing tag so it lands on its own unindented line.

Also adds integration tests covering non-streaming, streaming-complete,
and streaming-unclosed think block paths.

Co-authored-by: H <trulyshelton@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:02:55 -07:00
Arjun Pramanik
bfc7a4e017
docs(ai): unify AGENTS.md and CLAUDE.md (#2358) 2026-05-09 14:27:22 -07:00
Octopus
c7a1d36b3b
fix: remove 100ms IME timeout causing Enter key delay (fixes #2354) (#2367)
The previous implementation used compositionstart/compositionend events
with a 100ms timeout to track IME state. This caused Korean (Hangul)
IME users to experience a noticeable delay when pressing Enter to send:
the composition-ending Enter was blocked, and subsequent Enters within
100ms were also blocked, requiring users to hold the key or wait.

In Chromium/Electron, the keydown event for IME-confirming Enter fires
with isComposing=true (before compositionend), so the explicit ref-based
tracking is redundant. Relying directly on event.isComposing and the
legacy keyCode 229 is sufficient and matches the simpler approach already
used in follow-up-input.tsx.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-05-09 14:15:25 -07:00
Emt-lin
506de5477b
feat: migrate project storage from data.json to vault files (#2324)
* feat: migrate project storage from data.json to vault files

Migrate project definitions and metadata from data.json to individual
markdown files in the vault's projects directory.

- Project file manager with YAML frontmatter parsing and persistence
- Migration logic from legacy data.json format with unsupported/ backup
- Project UI components (AddProjectModal, ProjectList, ProgressCard)
- Chat history integration for the new storage model
- Status sorting, cache-first ordering, and URL removal in processing
- Atomic cache operations to prevent transient disappearance on rename
- Awaited context cache cleanup on all delete/rename/folder-switch paths
- Raw-content-first frontmatter parsing to avoid stale metadataCache
- BOM/CRLF support in frontmatter read and write helpers
- Accessibility: aria-labels on all icon-only buttons

* fix: address codex review feedback

- Defer vault listener registration from constructor to initialize()
  to avoid premature cache mutations and frontmatter writes during
  initial vault load (before onLayoutReady).

- Coerce numeric YAML project IDs to strings so bare numeric values
  like `copilot-project-id: 123` are not silently dropped.
2026-05-09 12:21:04 -07:00
Wenzheng Jiang
94737297b7
fix(miyo): strip vault folder-name prefix from indexed paths (#2390)
Miyo returns indexed file paths prefixed with the owning folder name
(the vault name), but getVaultRelativeMiyoPath was stripping the
absolute filesystem path prefix instead. As a result, the "List
Indexed Files" command rendered every entry as a broken Obsidian link
(e.g. [[MyVault/notes/foo.md]] instead of [[notes/foo.md]]).

Switch the strip logic to match what Miyo actually returns. Files from
other vaults pass through unchanged so cross-vault search results stay
intact. Always return a forward-slash-normalized path so callers see a
consistent separator. Drop the now-unused getVaultBasePath helper.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:21:07 +09:00
Zero Liu
37bea9a65c
fix(ollama): route through safeFetch when enableCors is set (#2382)
Other OpenAI-compatible providers (LM Studio, OpenAI-format, SiliconFlow,
etc.) already pass a fetch override so requests can go through Obsidian's
requestUrl API. The Ollama branch was missing this wiring, so toggling
"Enable CORS" on an Ollama model had no effect and mobile (WKWebView)
requests to http:// Ollama hosts failed with "Load failed" due to CORS /
mixed-content blocking.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:48:45 -07:00
Wenzheng Jiang
95d23b2387
release: v3.2.8 (#2369)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:43:24 +09:00
Wenzheng Jiang
2e3d6ba0d2
feat(miyo): add toggle to search all indexed content (#2353)
* feat(miyo): add toggle to search all indexed content across folders

Allow users to opt into searching everything in Miyo by omitting the
folder_name from search requests. This is enabled by default so Copilot
searches all indexed content. When disabled, searches are scoped to the
current vault folder only. Only affects the search endpoint — other Miyo
APIs (parseDoc, listFolderFiles, etc.) still send folder_name.

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

* fix(miyo): default miyoSearchAll to false

Scope searches to the current vault folder by default, which is the
safer default. Users can opt into cross-folder search via the toggle.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:15:57 +09:00
Wenzheng Jiang
1960d86a3a
release: v3.2.7 (#2352)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:31:09 +09:00
Wenzheng Jiang
d8890ada6d
refactor(miyo): rename folder_path request param to folder_name (#2349)
Miyo server now supports folder_name, which better reflects the
semantic that we send a vault name rather than a filesystem path.
Also removes folder_path from response interfaces as the server
no longer returns it.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:49:20 +09:00
Wenzheng Jiang
c350f37d98
refactor(miyo): rename folderPath to folderName and send relative path in parse-doc (#2348)
The Miyo folder identifier is actually the vault name, not a filesystem
path. Rename getMiyoFolderPath -> getMiyoFolderName and all folderPath
params -> folderName across MiyoClient and callers to reflect this.

For parse-doc, send vault-relative file.path instead of absolute path,
and include folder_path in the request body for consistency with all
other Miyo endpoints. Wire-format keys (folder_path) are unchanged.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:05:24 +09:00
Logan Yang
8577705cba
fix(debug): remove idx column from search results debug table (#2344)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 05:15:55 -07:00
Wenzheng Jiang
ccf687b5cb
Use vault name for Miyo requests (#2342) 2026-04-05 22:21:20 +09:00
Wenzheng Jiang
7084705067
release: v3.2.6 (#2335)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:25:46 +09:00
Wenzheng Jiang
81fd4e5157
feat(miyo): add remote vault folder path override for remote servers (#2334)
When using a Miyo remote server, the vault may be mounted at a
different path on the remote machine. This adds an optional
"Remote Vault Folder" setting that overrides the folder path sent
to Miyo when a custom server URL is configured.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:17:17 +09:00
Wenzheng Jiang
47e61259d2
Refactor Miyo integration for folder API (#2331)
* refactor Miyo integration for folder API

* chore: remove Miyo related-search log

* docs: add Miyo Node Service API reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:05:34 +09:00
Wenzheng Jiang
c0d79efb7c
feat(miyo): disable miyo on mobile without a remote server URL (#2328)
* feat(miyo): disable miyo on mobile without a remote server URL

Adds `shouldUseMiyo()` to `miyoUtils.ts` as the single source of truth
for whether Miyo should be active. On mobile, Miyo is silently ignored
unless an explicit remote server URL is configured, since local service
discovery is unavailable on mobile. The three previously duplicated
`shouldUseMiyo` checks in RetrieverFactory, findRelevantNotes, and
VectorStoreManager now delegate to this shared function.

Also drops the redundant `enableSemanticSearchV3` guard — the UI already
enforces that enabling Miyo enables semantic search and disabling semantic
search disables Miyo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(miyo): align RelevantNotes UI check with shouldUseMiyo

Replaces the local `shouldUseMiyoIndex` predicate (which duplicated the
self-host grace-period logic and missed the mobile platform guard) with a
direct call to `shouldUseMiyo` from miyoUtils. This ensures the UI's
index-state check matches what the backend actually uses, so the
"Build Index"/"No relevant notes" state is always correct on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(miyo): update findRelevantNotes mock to use shouldUseMiyo

Replace the isSelfHostAccessValid mock with shouldUseMiyo, which is now
the single gate checked by findRelevantNotes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:12:23 +09:00
Wenzheng Jiang
d6283e3151
feat(miyo): rename vault name to remote vault path and align with server API (#2326)
* feat(miyo): rename vault name to remote vault path and align MiyoClient with server API

- Rename `miyoVaultName` setting to `miyoRemoteVaultPath` with migration from old key
- Rename UI field "Vault Name" → "Remote Vault Path (Optional)" and "Custom Miyo Server URL" → "Remote Miyo Server URL (Optional)"
- Always show Remote Vault Path field (not gated by server URL); both fields default to blank with no placeholder
- Show bot name indicator (remote vault path + local/remote label) under Enable Miyo when enabled
- Rename `sourceId` parameters in MiyoClient public methods to `vault` to match the actual server API field name
- Update `searchRelated` options property from `sourceId` to `vault`
- Update call sites in findRelevantNotes.ts accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(miyo): remote vault path saves silently without triggering re-index

It is a remote-only identifier — changing it has no effect on the
local index, so no confirmation modal or re-indexing is needed.
Also drops the now-unused logWarn import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(miyo): remote vault path only overrides when remote server URL is also set

getMiyoSourceId now requires both miyoRemoteVaultPath and miyoServerUrl to be
set before using the remote path — if only the path is set (no server URL),
it falls back to the auto-detected local vault path. The settings indicator
applies the same logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(miyo): rename getMiyoSourceId → getMiyoVault, resolveMiyoSourceId → resolveMiyoVault

Removes the last "sourceId" terminology — all Miyo identifiers now consistently
use the "vault" name that the server API expects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(miyo): only show remote vault path indicator when remote server URL is set

Avoids showing the auto-detected local vault path under a "Remote vault path"
label, which was misleading when Miyo was running locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(miyo): replace custom remote vault path input with plain text field

Removes the Apply button, pending state, useEffect, and handler — the field
now saves with the standard debounced onChange, matching the URL field above it.
Also drops the unused Input and useEffect imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(miyo): show effective vault path under Enable Miyo when enabled

Displays the auto-detected local vault path when no remote server is
configured, or the remote vault path override when both remote URL and
remote vault path are set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): update findRelevantNotes test to use vault instead of sourceId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:24:29 +09:00
Logan Yang
23fb3b42ef
chore: improve release agent to read PR descriptions for better notes (#2319)
The release agent now fetches and reads every PR's description before
writing release notes, instead of relying on titles alone. This
produces more accurate, user-facing release notes.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:26:52 -07:00
Logan Yang
79877e3ef5
feat(openrouter): add per-model toggle for prompt caching (#2318)
* feat(openrouter): add per-model toggle for prompt caching

OpenRouter's Zero Data Retention (ZDR) endpoints do not support
Anthropic's automatic prompt caching (cache_control), causing 404
errors. This adds an `enablePromptCaching` option (default: true)
so users can disable it per model in settings.

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

* feat(openrouter): show question mark tooltip for prompt caching toggle

Display a visible HelpCircle icon next to the label instead of making
the label itself the hover target. Tooltip text: "Disable if your
OpenRouter endpoint uses Zero Data Retention (ZDR)..."

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

* fix: move prompt caching toggle to top-level model settings

The toggle was nested inside the advanced parameters section. Move it
to the top level alongside Base URL and API Key so it's immediately
visible to OpenRouter users.

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

* fix(ui): simplify prompt caching to single checkbox with tooltip

Remove the redundant FormField heading — now just a checkbox labeled
"Prompt Caching" with a ? tooltip explaining ZDR.

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

* fix: prompt caching applies to multiple providers, not just Anthropic

OpenRouter's cache_control supports OpenAI, Anthropic, DeepSeek,
Gemini, Grok, and others. Remove "Anthropic" from tooltip and docs.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:20:54 -07:00
Logan Yang
01c2c7c9fc
release: v3.2.5 (#2314)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:00:02 -07:00
Logan Yang
8b2b694e21
fix(ui): show enabled models without API keys as disabled in dropdown (#2313)
* fix(ui): show enabled models without API keys as disabled in dropdown

Previously, enabled models without API keys were hidden from the chat
input model dropdown, making it confusing since "Enable" in settings
didn't mean the model would appear. Now they show as greyed-out disabled
items with a "Needs API key" label.

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

* chore: reorder BUILTIN_CHAT_MODELS so enabled models are listed first

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 06:47:50 -07:00
Logan Yang
8e9970afd3
feat: add PR pricing agent for sizing and pricing PRs (#2312)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 05:43:42 -07:00
Wenzheng Jiang
ddb16af59c
feat(miyo): add customizable vault name setting (#2311)
* feat(miyo): add customizable vault name setting for Miyo indexing

Allows users to set a custom vault name that Miyo uses as the source ID
for indexing, instead of the auto-detected vault path. When changed while
Miyo is enabled, clears the old index and triggers a full re-index under
the new name. Includes guidance for remote/multi-device setups to use a
consistent vault name across devices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(miyo): compare resolved source IDs before clearing index on vault name change

Avoids unnecessary index clear and full re-index when the user types the
auto-detected vault path (the value that the blank field resolves to),
since the effective Miyo source ID is unchanged in that case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(miyo): prevent applying an empty vault name

The Apply button is hidden when the input is blank, and the handler
guards against an empty string, so users cannot accidentally clear
the vault name and submit it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(miyo): allow clearing vault name to restore auto-detected vault path

An empty vault name is valid — it falls back to the auto-detected vault
path as the Miyo source ID. Removes the empty-string guard so users can
clear a previously set name and revert to the default behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:57:06 +09:00
Wenzheng Jiang
d308423315
Composer V2: Replace replaceInFile with editFile as the primary targeted-edit tool (#2305)
* feat(editFile): write-first with inline diff in chat

Replace the blocking accept/reject modal in editFile with a write-first
approach: changes are written to disk immediately, and a unified diff is
returned inline in the chat as a fenced \`\`\`diff block. Multiple
editFile calls in a single agent turn each produce their own diff
without requiring any user interaction.

Also cleans up associated rename/refactor of writeToFileTool →
writeFileTool across chain runner, streamer, and related tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editFile): add Show Diff button and side-by-side diff view in ApplyView

- Pipe editFileDiffs through message types (ChatMessage, StoredMessage) and
  MessageRepository so writeFile/editFile diffs are stored as a typed field
  (same pattern as sources)
- Collect diffs in AutonomousAgentChainRunner.runReActLoop() by parsing the
  JSON result of successful writeFile/editFile tool calls, then pass through
  ReActLoopResult → handleResponse() → ChatMessage.editFileDiffs
- ChatSingleMessage reads message.editFileDiffs directly (instead of trying
  to parse TOOL_CALL markers which don't exist in agent mode)
- ChatButtons shows a "Show diff" button when editFileDiffs is present;
  clicking it opens ApplyView for each diff
- ApplyView redesigned: renders change blocks side-by-side (original left
  with red highlights, modified right with green highlights) using the
  SideBySideBlock component, unchanged blocks shown as plain text between
  change blocks — restores the classic diff block presentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert "feat(editFile): add Show Diff button and side-by-side diff view in ApplyView"

This reverts commit 80945493b48d50c26b9a01bc40de5eced2fcfd18.

* feat(editFile): show ApplyView with simple mode (no per-block buttons)

- editFile now shows the Apply View preview and blocks until user accepts/rejects
- Respects autoAcceptEdits setting (bypasses preview when enabled)
- Adds `simple` flag to ApplyViewState to hide per-block accept/reject/revert buttons
- Removes unused createPatch import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tools): update writeFile description to "Create or rewrite files in your vault"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address Codex review — GPT prompt, fuzzy match safety, tool ID migration

- modelAdapter: update GPT prompt to use editFile's oldText/newText params
  instead of the old SEARCH/REPLACE block syntax
- ComposerTools: fix fuzzy match to replace only the matched span in the
  original content; previously the entire file was rebuilt from the
  fuzzy-normalized string, inadvertently applying trimEnd and quote/dash
  normalization to text outside the matched span
- settings/model: migrate persisted autonomousAgentEnabledToolIds from
  writeToFile→writeFile and replaceInFile→editFile on upgrade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(editFile): add unit tests for fuzzy match via applyEditToContent

Extract pure replacement logic into applyEditToContent() for testability,
then wire editFileTool.func to call it (removes duplicated logic).

Tests cover:
- Exact match: basic replacement, deletion, NOT_FOUND, AMBIGUOUS
- Fuzzy via smart quotes (single, double), en-dash, NBSP
- Fuzzy via trailing whitespace (spaces, tabs)
- Key invariant: fuzzy match only replaces the matched span — smart quotes
  and trailing spaces outside the span are left untouched
- Multiline fuzzy match with mixed artifacts
- CRLF and LF preservation
- BOM preservation

Also fixes mapFuzzyIndexToNormal to extend matchEnd to the full original
line length when the fuzzy match ends at end-of-line, preventing orphaned
trailing whitespace after replacement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address Codex review — NFKC position mapping and legacy tag compat

- ComposerTools: fix mapFuzzyIndexToNormal to handle NFKC expansion
  (e.g. Ⅳ → IV) by walking char-by-char within each line instead of
  assuming a 1:1 column mapping; adds two unit tests covering this case
- ChatSingleMessage: normalise legacy <writeToFile> tags to <writeFile>
  before rendering so saved chat history from before the tool rename
  continues to display correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(modelAdapter): update assertions for editFile oldText/newText prompt

Replace stale SEARCH/REPLACE references with the new parameter names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(editFile): replace sentinel strings with ApplyEditResult discriminated union

- `applyEditToContent` now returns `{ ok: true; content: string } | { ok: false; reason: 'NOT_FOUND' } | { ok: false; reason: 'AMBIGUOUS'; occurrences: number }` instead of raw sentinel strings, eliminating false positives when file content matches 'NOT_FOUND' or starts with 'AMBIGUOUS:'
- Update `editFileTool` to pattern-match on the discriminated union
- Update `cleanMessageForCopy` regexes to also strip legacy `<writeToFile>` tags from saved chat history
- Update all `applyEditToContent` test assertions to use the new structured return type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editFile): sanitize file path before vault lookup

Apply `sanitizeFilePath` in `editFileTool` to match the same treatment
`writeFileTool` already does, preventing "File not found" failures when
the path has a basename that exceeds 255 bytes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editFile): guard against degenerate fuzzy match inside NFKC expansion

When `oldText` fuzzy-matches a span whose boundaries both map to the same
position in the original (e.g. searching "I" inside fuzzy "IV" derived from
"Ⅳ"), `matchStart >= matchEnd` and the replacement would be zero-width or
corrupt. Return NOT_FOUND instead of silently producing a wrong edit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(builtinTools): remove invalid daily:append/daily:prepend from prompt

The obsidianDailyNote tool schema only accepts 'daily', 'daily:read', and
'daily:path'. The prompt was incorrectly advertising non-existent
daily:append and daily:prepend commands, which would cause LLM-generated
tool calls to fail schema validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editFile): add Stage 3 to tolerate trailing newline mismatch at EOF

LLMs frequently append \n to the last line of oldText even when the file
has no final newline. Neither exact nor fuzzy match would find this, causing
avoidable NOT_FOUND failures. Stage 3 retries with one trailing \n stripped
from oldText (and mirrors the trim on newText to preserve the file's
no-trailing-newline format).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): update editFile integration test to oldText/newText shape

The argumentValidator was still asserting the legacy args.diff SEARCH/REPLACE
payload. Updated to check args.oldText and args.newText to match the new
editFile tool contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editFile): count overlapping matches to prevent wrong-location edits

countOccurrences was advancing by searchText.length, so overlapping patterns
like "aba" in "ababa" were counted as 1 (non-overlapping) instead of 2. This
caused an ambiguous match to be silently applied at the first position rather
than returning AMBIGUOUS. Advance by 1 to catch all overlapping occurrences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editFile): round-trip check guards partial NFKC-expansion fuzzy matches

The existing matchStart >= matchEnd guard only caught degenerate zero-width
spans (e.g. "I" in "Ⅳ"). A partial match like "V" against "Ⅳ" produces a
valid non-zero span [0,1) but would replace the entire source character even
though "V" doesn't exist standalone in the file.

Fix: after mapping indices back to the original, verify that
normalizeForFuzzyMatch(original[matchStart:matchEnd]) === workingSearch.
If not, the match straddled an NFKC expansion boundary → return NOT_FOUND.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editFile): return structured failed result on edit errors

Replace plain string returns with `{ result: "failed", message }` objects
for all failure paths in editFileTool (file not found, text not found,
ambiguous match, and exceptions), matching writeFileTool's pattern so
the LLM and result formatters can reliably detect failures via the
`result` field. No-op case returns `result: "accepted"` since the edit
technically succeeded.

Also fix a stale tool name reference in builtinTools prompt instructions
(writeToFile → writeFile).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 18:51:39 +09:00
Logan Yang
e3d47adfa2
feat(lm-studio): use Responses API for LM Studio models (#2306)
Switch LM Studio from /v1/chat/completions to /v1/responses via a thin
ChatLMStudio wrapper that patches LangChain compatibility issues
(text.format requirement, strict:null in tool definitions).

- New ChatLMStudio class with fetch wrapper for tool sanitization
- Opt-out toggle in model settings (useResponsesApi)
- Ping uses ChatLMStudio to test the correct endpoint
- ThinkBlockStreamer: strip special tokens from text content

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:37:38 -07:00
Wenzheng Jiang
8b43355c77
fix(search): allow remote backends to re-index on mobile when disableIndexOnMobile is enabled (#2308)
The disableIndexOnMobile guard was blocking all re-indexing on mobile,
including the Miyo backend which is a remote HTTP service with no local
index. Add isRemoteBackend() to SemanticIndexBackend interface so guards
in IndexEventHandler and VectorStoreManager can skip the check for
remote backends.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:21:38 +09:00
Logan Yang
ce7c51f336
refactor(tools): remove daily note append/prepend CLI commands (#2307)
Remove daily:append and daily:prepend from the obsidianDailyNote tool.
These commands overlapped with writeToFile/replaceInFile, creating
ambiguity for the agent when the user asks to add content to a daily
note. Users should use writeToFile or replaceInFile instead.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 05:49:49 -07:00
Michael Ryan
87ee9d0d56
fix(rename): Add automatic file renaming to match generated topic titles (#2240)
* fix(rename): Add automatic file renaming to match generated topic titles

Adds automatic file renaming so that notes reflect the finalized AI-generated
topic in their filename. I believe this restores previously expected behavior.

Refs #2005

* Fix chat file renaming to respect original project and hidden directories

- Pass captured project context to generateFileName for async rename,
  ensuring the filename uses the project active at save time rather than global current-project state.
- Add fallback for hidden-directory files when reading epoch for rename,
  using the vault adapter if metadataCache is missing, so AI-generated topic renaming works for hidden folders.

Fix #2240

* fix: honor explicit null project context in async chat renaming

Refactor ChatPersistenceManager to distinguish between 'undefined' project (fallback to global) and 'null' project (explicitly no project). This prevents a race condition where non-project chats were incorrectly moved into project directories upon async file renaming.

Key changes:
- Updated 'generateFileName' to use strict undefined checking for project fallback.
- Reordered method signatures for 'generateFileName', 'generateTopicAsyncIfNeeded', and 'renameFileToMatchTopic' to prioritize the project context.
- Verified all internal call sites have been updated to match the new signatures.
- Confirmed fix via unit tests and local compilation.
2026-03-16 04:34:14 -07:00
Logan Yang
1bf571d63f
fix(tools): daily note template workflow for past/future dates (#2304)
* fix(tools): improve daily note template workflow for past/future dates

Update prompt instructions for creating past/future daily notes to
use template:read for template content, with a fallback to ask the
user for the template path if the Templates plugin isn't configured.

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

* fix(tools): list templates before reading in daily note workflow

The workflow told the agent to call template:read directly without
the required name argument. Now it explicitly lists templates first
to discover the name, then passes it to template:read.

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

* fix(tools): route task queries to obsidianTasks instead of localSearch

Add explicit prompt instruction to always use obsidianTasks for task
queries, not localSearch. For time-based task queries, use verbose
mode and filter by file dates. localSearch searches note content but
doesn't understand markdown checkbox semantics.

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

* fix(tools): reduce reasoning noise and fix tasks schema hint

Skip redundant success reasoning steps (e.g. "Listed vault tasks"
after "Listing vault tasks"). Only show result steps on failure or
when they add info (search source counts).

Also fix tasks tool schema: the LLM kept guessing command="list"
instead of "tasks" because z.literal lacked a clear hint. Added
'Must be exactly "tasks"' to the description.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:30:05 -07:00
Logan Yang
00fa339506
feat(tools): add base:create command and .base active note support (#2303)
* feat(tools): add base:create command and .base active note support

Add `base:create` to the obsidianBases CLI tool, enabling creation of
new items in Obsidian Bases through natural language. Also add .base
files as recognized active notes so they appear in chat context.

Changes:
- Add base:create command with name, content, view params to CLI tool
- Support .base files as active notes in chat context
- Add TEXT_READABLE_EXTENSIONS and ALLOWED_NOTE_CONTEXT_EXTENSIONS to
  constants.ts as single source of truth for file extension checks
- Use isTextReadableFile() in contextProcessor and utils instead of
  hardcoded extension checks
- Register .base in MarkdownParser for content reading
- Fix inaccurate filter syntax in Base YAML prompt instructions
- Fix broken lint-staged eslint flag (--no-warn-ignored not in eslint 8)
- Update design doc with base:create in v1 tier and write policy

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

* fix(tools): improve LLM routing for .base file operations

Add explicit disambiguation in both writeToFile and obsidianBases
prompts to prevent the LLM from using Composer for .base operations.
writeToFile now excludes .base files by default, and obsidianBases
declares itself as the primary tool for all .base operations with
clear examples of when each tool should be used.

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

* fix(tools): add execution-time guard for .base file routing

Add a code-level guard in executeSequentialToolCall that intercepts
writeToFile/replaceInFile calls targeting .base files and redirects
the LLM to use obsidianBases instead. This is more reliable than
prompt-only disambiguation since it works regardless of what the LLM
decides.

The guard only fires when obsidianBases is available (desktop). On
mobile or when the tool is disabled, writeToFile proceeds normally.
The error message tells the LLM exactly which commands to use, so it
self-corrects on the next iteration.

Also simplifies the prompt instructions by removing the verbose
disambiguation text that wasn't working reliably.

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

* fix(composer): respect user auto-accept setting over LLM confirmation param

The writeToFile tool allowed the LLM to bypass the diff preview by
passing confirmation=false, even when the user had not enabled
auto-accept edits in settings. Now the preview is always shown unless
the user has explicitly enabled auto-accept. The LLM's confirmation
parameter is ignored.

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

* feat(tools): add daily command to create today's daily note

The agent had no way to create a daily note - daily:append/prepend
require non-empty content and don't apply templates. Add the CLI
`daily` command which creates today's daily note with the user's
configured template and folder from the core Daily Notes plugin.

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

* fix(tools): allow composer to create new .base files

The execution-time guard was blocking writeToFile for ALL .base paths,
including creating brand new .base files. Now the guard only redirects
when the .base file already exists (editing an existing base should
use CLI). Creating a new .base file via composer proceeds normally.

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

* fix(tools): improve Base YAML filter syntax and add validation step

Fix LLM generating invalid Base YAML by adding explicit filter format
rules: single string or and/or/not objects only, bare YAML lists are
invalid. Also add a validation step that calls base:views after
creating a .base file to verify it parses correctly, enabling the
agent to self-correct on errors.

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

* fix(tools): remove .base file execution guard

The guard was too aggressive - it blocked legitimate .base YAML edits
(adding columns, changing filters) because the CLI has no commands to
modify .base file structure. Editing .base YAML requires writeToFile.
Routing between CLI (base:create, base:query) and composer (YAML
edits) is now handled entirely by prompt instructions.

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

* fix: restrict embedded note segment parsing to markdown files only

Revert the embed gate from isTextReadableFile() back to .md-only.
The extractMarkdownSegment logic treats # as a heading reference,
but .base files use # for view names (![[Library.base#To Read]]).
Routing .base embeds through heading parsing would fail and return
an error block instead of usable context.

The chain restriction check (Plus-mode gating) correctly uses
isTextReadableFile since it's about access control, not parsing.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:08:14 -07:00
Logan Yang
31492eb159
feat: enhance CLI tool instructions from obsidian-skills reference (#2301)
Improve agent prompt instructions for all Obsidian CLI tools based on
kepano/obsidian-skills reference:

- dailyNote: add inline flag, escape sequences, template workflow
- properties: add sort=count, file= vs path= targeting, modification tip
- tasks: add status character filter examples
- links: add counts, all flag, file targeting docs
- bases: add full YAML structure, filter syntax, formula functions,
  view types, property types, quoting rules, and embed syntax

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:19:44 -07:00
Logan Yang
31ad9b825c
fix(agent): improve inline citations, query dedup, and answer source priority (#2300)
* fix(agent): improve inline citations, query dedup, and answer source priority

- Make extractSourcesSection handle --- separator and bare trailing
  footnotes so agent mode citations become clickable
- Re-add citation format reminder before [User query]: via
  getCitationFormatReminder + injectGuidanceBeforeUserQuery
- Move linkInlineCitations to run after all segments are rendered
- Improve query dedup metric to max(jaccard, containment) with 0.6
  threshold for better subset-style duplicate detection
- Add 4-tier answer source priority: explicit context > vault > web >
  baked-in knowledge

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

* perf: defer linkInlineCitations until streaming completes

Skip DOM-walking citation linkage during streaming since the sources
section is incomplete and the DOM is rebuilt every chunk anyway.
Citations become clickable all at once when the stream finishes.

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

* fix: address Codex review comments - HR sources validation and lastIndexOf

- Strategy 2 of extractSourcesSection now requires ALL non-empty lines
  after the --- separator to be footnote definitions, preventing content
  loss when --- appears as a divider before mixed content (P1)
- injectGuidanceBeforeUserQuery uses lastIndexOf instead of indexOf to
  target the final [User query]: label, avoiding misinjection when tool
  output contains that literal string (P2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: citation placeholder badges during streaming

- Wrap raw [^N] footnote marks in placeholder spans during streaming
  to prevent markdown renderer from showing bare superscript numbers
- Style placeholders as small muted superscript pills (copilot-citation-ref)
- Style final clickable citations as matching superscript pills with
  accent color (copilot-citation-group)
- Seamless visual transition: same pill shape, muted -> accent colored

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 23:41:31 -07:00
Logan Yang
728ccb25d4
fix: restore expanded search limits for time-range and tag queries (#2295)
* fix: restore expanded search limits for time-range and tag queries

PR #2273 removed the agent-facing returnAll parameter but also
removed the implicit expanded limits that time-range and tag-focused
queries relied on. This restores the behavior: when a search has a
timeRange or tag terms, automatically use RETURN_ALL_LIMIT (100)
instead of the default maxSourceChunks, and pass returnAll to the
underlying retrievers. The agent still cannot explicitly request
returnAll via the tool schema.

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

* feat: two-tier search result formatting to reduce context bloat for expanded queries

When search results exceed maxSourceChunks, split into two tiers:
- Tier 1 (first maxSourceChunks docs): full content XML as before
- Tier 2 (overflow docs): metadata-only with title, path, mtime, 150-char snippet

Achieves ~85% context reduction for expanded-limit queries while preserving
discoverability. Adds formatMetadataOnlyDocuments() as a pure, testable utility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: two-tier search formatting for time-range and tag queries

- Add isFilterOnlyResults() and isTimeDominantResults() helpers in searchResultUtils
- Update formatMetadataOnlyDocuments() with configurable snippetLength (default 300)
- Update prepareLocalSearchResult() with three-case logic:
  - Time-dominant: sort by mtime desc, top maxSourceChunks get full content, rest metadata-only
  - Tag-only: all docs get metadata-only (no ranking available)
  - Ranked: existing behavior unchanged
- Extract FILTER_SOURCES to module-level constant to avoid per-call Set allocation
- Add tests for isFilterOnlyResults, isTimeDominantResults, and custom snippetLength

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update readNote instruction in metadata-only search results

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

* fix: address Codex P1/P2 review findings for two-tier search

P1: Remove title-match from FILTER_SOURCES so explicit note references
([[Note Name]]) get full content in tier 1, not metadata-only. This
restores prior behavior where title-match queries provided full content
without requiring an extra readNote tool call.

P2: Fall back to parsing tags directly from the query string when
salientTerms has no tag terms. Mirrors the regex logic in FilterRetriever
so needsExpandedLimits is true even when salientTerms is incomplete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: remove unrelated Prettier formatting changes

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

* fix: preserve no-results marker and sanitize metadata snippets

- Guard the metadata-only path with tier2Docs.length > 0 so empty-result
  searches fall through to formatSearchResultsForLLM and return the
  expected "No relevant documents found." marker instead of an empty string.
- Apply sanitizeContentForCitations to doc.content in
  formatMetadataOnlyDocuments before slicing the snippet, preventing
  leaked citation markers (e.g. [^12], [3]) from note content into the
  tier-2 metadata prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: decouple timeDominant detection from filterOnly

Time-range queries via FilterRetriever often produce a mix of
"time-filtered" and "title-match" (daily notes from getTitleMatches)
docs. Since "title-match" is not in FILTER_SOURCES, isFilterOnlyResults
returns false for these result sets, making the old `timeDominant =
filterOnly && isTimeDominantResults(...)` always false — skipping the
mtime-based recency sort for time-range queries.

Fix by evaluating isTimeDominantResults independently: if any doc has
source "time-filtered", use mtime sort regardless of whether all docs
are filter-only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: populate tagTerms from query when salientTerms misses hashtags

When salientTerms is incomplete (e.g. query="#python" with empty
salientTerms), tagTerms was always an empty array, so SelfHostRetriever
received no server-side tag filters despite expanding to RETURN_ALL_LIMIT.

Extract hashtags from the raw query using the same regex as FilterRetriever
as a fallback, and assign the result directly to tagTerms so all retriever
paths (including SelfHostRetriever) get proper tag constraints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:45:22 -07:00
Logan Yang
89711d5136
feat: add Gemini Embedding 2 preview model support (#2299)
Adds `gemini-embedding-2-preview` to the builtin embedding model list.
This is Google's latest multimodal embedding model with 8K token input
limit and up to 3072 output dimensions, offering improved quality over
the existing gemini-embedding-001.

Closes #2298

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:33:40 -07:00
Logan Yang
095a5f2011
chore: fix pre-commit hook to enforce formatting and linting (#2297)
The pre-commit hook existed but was not executable and used raw
prettier instead of lint-staged. This fixes:

- Make .husky/pre-commit executable (was -rw-r--r--)
- Use `npx lint-staged` instead of raw prettier/eslint commands
- Remove deprecated husky v4 config from package.json (hooks key)
- Remove unnecessary `git add` from lint-staged (auto-staged in v15)
- Add eslint --fix to lint-staged for JS/TS files

All contributors now get automatic formatting + linting on commit.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:10:05 -07:00
Logan Yang
1f2539712d
feat(openrouter): enable prompt caching via cache_control (#2279)
* feat(openrouter): enable prompt caching via cache_control field

Add `cache_control: { type: "ephemeral" }` to all OpenRouter invocation
params so Anthropic models automatically detect cache breakpoints and
reduce token costs. Other providers (OpenAI, DeepSeek, Gemini, Grok,
Groq) either auto-cache without this field or safely ignore it.

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

* fix(openrouter): gate cache_control to OpenRouter endpoints only

ChatOpenRouter is shared by LM_STUDIO and COPILOT_PLUS providers which
use their own OpenAI-compatible base URLs. Sending cache_control to
strict OpenAI-format backends causes 4xx request failures. Detect
OpenRouter by checking the baseURL and only inject cache_control when
talking to openrouter.ai.

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

* chore: update Claude Sonnet builtin model to claude-sonnet-4-6

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

* chore: update builtin models - add gemini-3.1-flash-lite-preview, reduce enabled defaults

- Update Claude Sonnet to claude-sonnet-4-6
- Add gemini-3.1-flash-lite-preview (enabled, projectEnabled)
- Disable most builtin models by default to reduce clutter
- Keep enabled: Copilot Plus Flash, OpenRouter Gemini 2.5 Flash,
  GPT-5.2, GPT-5 mini, Claude Sonnet 4.6, Gemini 2.5 Flash,
  Gemini 3.1 Flash Lite Preview
- Update Bedrock example placeholder to claude-sonnet-4-6

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:18:01 -07:00
Logan Yang
25e3faa088
feat: unify Azure OpenAI and Azure Foundry into single Azure provider (#2291)
* feat: unify Azure OpenAI and Azure Foundry into single Azure provider

Combines the two Azure providers into one. Users can now provide the
full endpoint URL as the base URL and skip the legacy instance name,
deployment name, and API version fields. The URL is automatically
normalized (strips /chat/completions, extracts api-version from query
params). Legacy field-based configuration still works for backward
compatibility.

Changes:
- Rename Azure OpenAI label to "Azure" with updated example host
- Remove azure-openai builtin model (users add models manually)
- Add normalizeAzureUrl() to handle full URLs pasted by users
- Make legacy Azure fields optional in ModelAddDialog when base URL set
- Update CURL generation to use base URL directly when provided
- Disable Responses API for Azure provider (not supported by Azure)
- Default api-version to 2024-05-01-preview when not specified

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

* fix: address Codex review - Azure embedding validation and curl model field

- Require legacy Azure fields (instanceName, deploymentName, apiVersion) for
  embedding models even when baseUrl is provided, since EmbeddingManager reads
  those fields directly and never consumes baseUrl
- Include model name in Azure base-URL curl payloads for both chat and embedding
  requests so the copied curl command works with Azure Foundry /models endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: show Azure legacy fields in UI for embedding models even when baseUrl is set

The renderProviderSpecificFields early-return was hiding instanceName,
embeddingDeploymentName, and apiVersion for all Azure models when baseUrl
was present. But validateFields() requires those fields for embedding models
regardless of baseUrl. Now only chat models skip the legacy UI fields when
baseUrl is provided; embedding models always render them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: trim baseUrl before validation and skip Responses API verbosity for Azure

- Use model.baseUrl?.trim() in validateFields() and renderProviderSpecificFields()
  so whitespace-only input cannot bypass required-field validation
- Skip text.verbosity in getOpenAISpecialConfig() for Azure models, since this
  PR intentionally excludes Azure from useResponsesApi and the Responses-API
  verbosity param would produce a broken payload for Azure GPT-5 with verbosity set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:41:14 -07:00
Logan Yang
d3ee3f999b
fix: agent loop improvements, clickable citations, and UI fixes (#2287)
* fix: prevent agent ReAct loop from getting stuck with small local models

Small local models (e.g. qwen3.5-4b-mlx) get stuck calling localSearch
repeatedly with near-identical queries and never synthesizing an answer.

- Remove QueryExpander from agent loop to avoid model contention on
  single-connection local models (retriever still expands internally)
- Add query deduplication that pre-filters tool call batches using
  Jaccard word-overlap similarity before execution
- Force synthesis via raw model (without tools) after 2 consecutive
  iterations where all tool calls are duplicates
- Add agent-specific system prompt guidance for search limits
- Strip leaked chat template role tokens from intermediate content
- Add intermediate model output logging for debugging

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

* feat: make inline citation numbers clickable and restore Max Sources setting

Inline citation numbers like [1], [2] in chat responses are now clickable
links that open the corresponding source note. Also restores the Max Sources
slider in QA settings, reverting the hardcoded DEFAULT_MAX_SOURCE_CHUNKS.

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

* fix: remove excessive 32px bottom padding from chat view on desktop

The padding-bottom on .view-content used max(safe-area + 4px, 32px),
forcing a 32px minimum even on desktop where safe-area-inset-bottom is 0.
Simplified to safe-area + 4px so desktop gets minimal padding while
devices with safe areas still get proper spacing.

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

* fix(agent): track dedup queries post-execution to keep failed searches retryable

Move previousSearchQueries tracking from before to after each localSearch tool
call executes. Previously, queries were marked as seen before any tool ran, so
if a call was aborted or the loop threw early, those queries were still blocked
from retrying on the next iteration. Now each query is added to
previousSearchQueries immediately after its tool call returns, keeping the dedup
effective for completed calls while leaving transient failures retryable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(citations): preserve Obsidian internal-link attributes on inline citation anchors

When building clickable inline citation links (e.g. [1], [2]), the previous code
only copied the href from the source section anchor. Obsidian vault-note links have
additional attributes such as data-href and class="internal-link" that are required
for in-app note navigation. Without them, clicking an inline citation resolves the
link as a plain relative URL instead of opening the corresponding note.

Fix: store the source anchor element rather than just the href, then copy all
attributes onto the generated citation anchor before setting the citation-specific
class and aria-label overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(agent): only mark localSearch queries as seen after successful execution

Change the dedup tracking condition from always tracking queries post-execution
to only tracking when the tool call succeeds (result.success is true). This
ensures transient failures (e.g. temporary index unavailability) leave the
query retryable: if a localSearch fails, the model can issue the same query
again on the next iteration. Previously, even a failed execution would mark
the query as seen, blocking recovery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: deduplicate inline citations after source consolidation

When multiple chunks come from the same note, the LLM cites them
separately. After consolidation maps them to the same source number,
adjacent duplicates like [1][1] or [1] and [1] now collapse to [1].

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

* chore: update GPT-5.2 to GPT-5.4 in builtin models

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

* fix: add dynamic status bar clearance for desktop chat view

The chat input was hidden behind Obsidian's status bar on desktop.
Adds ChatViewLayout which measures the geometric overlap between
the view-content and the status bar, setting a CSS variable to
provide the exact padding needed. Adapts to any theme: no clearance
for auto-hide themes (opacity: 0) or themes that already account
for the status bar in their layout; correct clearance for themes
where the bar is visible and overlapping. Re-checks on theme
switches via debounced css-change listener.

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

* fix: suppress thinking tokens during streaming when reasoning is disabled

When a local model (e.g. Qwen 3.5 via LM Studio) emits text-level
<think> tags but the user has not enabled the reasoning capability,
the thinking content was visibly rendered during streaming and only
stripped once the </think> tag arrived. Now tracks the position of
excluded think blocks across chunks and truncates fullResponse to
the safe prefix on each chunk, so thinking content never appears.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:17:37 -07:00
Logan Yang
1703b283d0
fix: add "None" option to system prompt dropdown in chat settings (#2293)
The chat settings popover (gear menu) was missing the "None (use
built-in prompt)" option that exists in the Advanced settings tab.
Users could not clear a custom system prompt from the chat settings.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:05:11 -07:00
Emt-lin
9fdc91f79a
fix: Quick Ask/Command panel positioning overhaul (#2278)
- Dual-anchor model: use selection.to for below, selection.from for above,
  with focusPos for reverse selections, preventing panel from obscuring text
- Visual multi-line detection via coordinate comparison instead of logical
  line numbers, fixing soft-wrap misclassification
- Extract computeVerticalPlacement() pure helper with regression tests
- Shared height constants (MODAL_MIN_HEIGHT_COMPACT/EXPANDED) eliminating
  fragile three-way coupling between positioning, modal, and DraggableModal
- anchorBottom support in DraggableModal for upward growth on above placement
- Consistent "center in editor" fallback across all insufficient-space cases
  for both Quick Ask and Quick Command
- Space checks use scrollRect (editor visible area) instead of window
- Multi-line selections center horizontally on the editor
- Horizontal clamp to scrollRect first, then viewport as safety net
- Reset DraggableModal transient state on reopen transitions
- Side lock to prevent flipping during streaming output
2026-03-13 21:50:01 -07:00
Logan Yang
09ce24115b
fix: defense-in-depth overrides to prevent tiktoken CDN timeout in Plus mode (#2283)
* fix: defense-in-depth overrides to prevent tiktoken CDN timeout in Plus mode (#2282)

Plus mode's planToolCalls uses invoke() on a streaming model, which
causes LangChain's _generate() to call _getEstimatedTokenCountFromPrompt
after streaming. This triggers getNumTokens -> encodingForModel -> CDN
fetch with 6 retries, hanging for minutes when tiktoken.pages.dev is
unreachable.

The existing getNumTokens instance override (PR #2160) is bypassed in
some production bundle configurations. Add overrides at two additional
levels: getNumTokensFromMessages and _getEstimatedTokenCountFromPrompt.
The latter returns 0 since actual token usage comes from API response
metadata.

Agent mode is unaffected because it uses stream() which bypasses
_generate() entirely.

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

* fix: use stream() instead of invoke() in planToolCalls to avoid tiktoken CDN fetch

Instance property overrides on getNumTokens are bypassed in esbuild
production bundles (prototype methods called directly). Instead of
trying to override methods, eliminate the problematic code path: replace
invoke() with stream() + chunk collection in planToolCalls.

invoke() goes through _generate() which, with streaming: true, calls
_getEstimatedTokenCountFromPrompt -> getNumTokensFromMessages ->
getNumTokens -> encodingForModel -> tiktoken CDN fetch (6 retries).

stream() goes through _streamResponseChunks() directly, bypassing
_generate() and the entire token estimation path. This is the same
approach Agent mode uses, which is why it was never affected.

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

* fix: patch getNumTokens on prototype instead of instance for esbuild compat

Instance property overrides are bypassed in esbuild production bundles
because minified code resolves this.method() calls directly to the
prototype. Patch the prototype with a guard flag to ensure the tiktoken
CDN fetch is prevented in all code paths including production builds.

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

* fix: patch tiktoken on BaseLanguageModel prototype and convert invoke to stream

Two fixes for the tiktoken CDN timeout that blocks web search:

1. Walk up the prototype chain to patch getNumTokens on
   BaseLanguageModel.prototype (where it's defined) instead of the
   direct prototype. One patch now covers ALL model classes
   (ChatOpenAI, ChatOpenRouter, etc.) instead of only the first
   class instantiated.

2. Convert getStandaloneQuestion() from invoke() to stream() to
   avoid _generate() -> _getEstimatedTokenCountFromPrompt() ->
   getNumTokens() -> tiktoken CDN fetch path entirely. This is
   the invoke() call triggered during @websearch tool execution.

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

* refactor: simplify tiktoken patch to one-time module-level override

Replace the per-instance prototype walking with a single module-level
patch on BaseLanguageModel.prototype.getNumTokens. Runs at import time
before any model is created, covering all model classes and all code
paths (createModelInstance, ping, etc.) with zero per-call overhead.

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

* fix: handle array content in getStandaloneQuestion stream loop

Use extractTextFromChunk() instead of a bare string check so that
providers sending content-part arrays (e.g. Anthropic) are handled
correctly during streaming. Previously array content was silently
dropped, which could produce an empty condensed question.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:48:13 -07:00
Logan Yang
cd726a0e83
feat: drag relevant notes and sources into editor to insert wikilinks (#2288)
* feat: drag relevant notes and sources into editor to insert wikilinks

Adds Obsidian-native drag-and-drop support to the Relevant Notes panel
and Sources modal, using app.dragManager so dropping onto an editor
automatically inserts [[wikilink]] without any custom drop handler.

- New useNoteDrag hook wraps app.dragManager.dragLink/onDragStart
- RelevantNotes: title link (expanded) and badge (collapsed) are draggable
- SourcesModal: source links are draggable (vanilla JS class context)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: prevent drop zone overlay when dragging from relevant notes

The chat container's "drop files here" overlay incorrectly appeared when
dragging notes from the relevant notes pane. Mark internal drags with a
custom data transfer type so the drop zone hook can skip them.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:56:55 -07:00
Emt-lin
ec5cdcfe25
fix: support both classic and modern YouTube transcript panel DOM structures (#2286)
YouTube now serves two different transcript panel types server-side:
- Classic ("In this video"): ytd-transcript-segment-renderer with .segment-timestamp
- Modern ("Transcript"): transcript-segment-view-model with .ytwTranscriptSegmentViewModelTimestamp

The old code only handled the classic panel, causing empty transcripts when
Web Viewer referenced a video with the modern panel.

Key changes:
- Panel-scoped queries via findTranscriptPanel() to avoid false matches
- Visibility-aware panel detection to skip hidden/stale panels
- extractFromPanel() auto-detects classic vs modern layout
- Close button uses captured panel element instead of hardcoded target-id
2026-03-12 20:59:56 -07:00
Logan Yang
6d14b3ee89
fix: strip leaked special tokens from local model responses (#2284) (#2285)
Local model servers (LM Studio, Ollama) sometimes leak chat template
control tokens like <|im_end|> into visible responses when stop tokens
are not configured on the server side.

Add stripSpecialTokens utility that removes known tokens (ChatML,
Llama 3, Gemma, Phi, Mistral, Qwen, DeepSeek, Command R) via a single
pre-compiled regex. Applied in ThinkBlockStreamer.handleDeepseekChunk
before content is appended to the response.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:36:31 -07:00
Emt-lin
0ac60eb31e
fix: command UI improvements, LaTeX rendering, and Ollama numCtx config (#2276)
* feat: add Lucide icons to all commands for mobile toolbar display

* fix: restore reactive settings subscription in ApiKeyDialog

* fix: render LaTeX in Quick Ask/Command and fix text selection

- Extract shared preprocessAIResponse() for LaTeX delimiter normalization
  and executable code block escaping (dataview/tasks)
- LaTeX normalization now skips fenced/inline code blocks
- Quick Ask: preprocess content before MarkdownRenderer call
- Quick Command: add markdown preview mode with edit toggle
- ChatSingleMessage: refactor to use shared preprocessAIResponse()
- Fix text selection in Quick Ask overlay (user-select on CM6 DOM)
- Add Copy button to command modal action bar
- Remove SelectedContent preview (editor highlight is sufficient)

* feat: add configurable context window size for Ollama models (#2275)

Replace hardcoded numCtx=131072 with a per-model configurable slider
in the model parameters UI. Default remains 131072 for backward
compatibility. Users can adjust it to reduce VRAM usage on GPUs with
limited memory.

Also replace raw provider strings with ChatModelProviders enum in
ModelParametersEditor for type safety, and add LM_STUDIO enum check
alongside existing "lm_studio" to handle both provider formats.
2026-03-10 23:29:17 -07:00
Yu Zou
46c319f505
fix(tools): replace cachedRead with vault.read in agent/tool paths (#2274) 2026-03-10 14:53:54 -07:00
Logan Yang
62540dc46b
fix: remove returnAll from agent-facing search tools to prevent token spikes (#2272) (#2273)
The agent was triggering returnAll too aggressively, causing massive token
usage. This removes returnAll from the tool schema, planning prompt, and
all search paths:

- Remove returnAll from localSearch Zod schema (no longer agent-settable)
- Remove OR stacking that expanded limits when time range or tags present
- Maintain similarity score floor at 0.1 (was dropping to 0.0)
- Remove returnAll from CopilotPlusChainRunner planning prompt and extraction
- Clean up agent prompt in builtinTools.ts
- All search paths now use DEFAULT_MAX_SOURCE_CHUNKS (30) consistently

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:59:03 -08:00
Logan Yang
41e2e552dd
fix: pass timeRange to Miyo search path (#2267) (#2269)
* fix: pass timeRange to Miyo search path (#2267)

When Miyo is active, localSearchTool called performMiyoSearch without
the timeRange parameter, causing time-based queries like "what did I do
this week" to ignore date filters and return wrong results.

Thread timeRange through to performMiyoSearch, FilterRetriever, and
MiyoSemanticRetriever — all of which already support it.

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

* chore: gitignore .claude/worktrees/

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:21:27 -08:00
Logan Yang
a0ba6eb0a9
feat: add obsidianBases CLI tool (read-only) (#2265)
* feat: add obsidianBases CLI tool (read-only)

Add obsidianBases — a read-only category-based CLI tool for querying
Obsidian Base (database) files. Supports three commands: bases (list all
Base files), base:views (list views in a Base file), and base:query
(query data from a Base view).

Follows the same pattern as existing v1 CLI tools (obsidianProperties,
obsidianTasks, obsidianLinks, obsidianTemplates).

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

* fix: clarify file/path field descriptions in obsidianBases schema

Change "Required" to "Used" in file description and add "Alternative to
file=" in path description to accurately reflect that either file OR path
satisfies the requirement, not both.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:44:43 -08:00
Logan Yang
d849ade60a
Add Obsidian CLI daily/random read tools and reasoning summaries (#2181)
* Add Obsidian CLI daily/random read tools with fallback + reasoning summaries

* chore: move CLI design doc to designdocs/ and remove old TODO doc

- Move docs/OBSIDIAN_CLI_INTEGRATION_DESIGN.md to designdocs/OBSIDIAN_CLI_INTEGRATION.md
- Remove designdocs/todo/OBSIDIAN_CLI_INTEGRATION.md (replaced by PR's design doc)

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

* fix: gate CLI tools behind Platform.isDesktopApp so they are invisible on mobile

- Move CLI tool registration from static BUILTIN_TOOLS array to registerCliTools()
- Only call registerCliTools() when Platform.isDesktopApp is true
- On mobile: tools are not registered, not shown in settings/UI/reasoning
- Update design doc with platform policy and CLI approach rationale

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

* docs: update CLI design doc with finalized category-based tool roadmap

- Replace generic v0/v1/v2 tiers with category-based tool grouping
- Define tools: obsidianDailyNote, obsidianProperties, obsidianTasks,
  obsidianFiles, obsidianLinks, obsidianTemplates, obsidianBases,
  obsidianBookmarks, obsidianTags
- Document excluded commands (destructive, plugin/theme, sync, dev tools)
- Add design rationale for category-based approach
- Update rollout plan to match new tool structure

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

* docs: remove search commands from v1 roadmap

search and search:context are redundant with Copilot's existing
keyword + semantic search. Update obsidianFiles to only include
read, append, prepend, random:read. Add search to excluded list.

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

* docs: restrict file writes to daily notes, add write operations policy

- Remove append/prepend from obsidianFiles (now read-only: read, random:read)
- Daily note append/prepend: direct execution, no Composer diff needed
- Property set/remove and task toggle: require light confirmation
- Arbitrary file writes excluded — use existing Composer tool instead
- Add Write Operations Policy section to design doc

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

* docs: remove redundant CLI tools from roadmap, add tool disambiguation

Remove `read` from obsidianFiles (redundant with readNote) and drop
obsidianTags category (redundant with getTagList). Add Tool
Disambiguation section documenting overlap analysis and prompt
instruction guidelines for CLI tools.

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

* docs: defer confirmation-required CLI mutations from v1 to v2

Move property:set, property:remove, and task toggle/status to v2 tier.
v1 now contains only read-only tools + direct-execution daily note
writes. This ensures v1 ships without needing confirmation UX.

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

* docs: add Appendix A with v1 CLI command reference

Document all 12 v1 CLI commands with parameters, output format, and
real examples. Covers obsidianDailyNote, obsidianProperties,
obsidianTasks, obsidianRandomRead, and obsidianLinks tools. Includes
error response reference.

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

* feat: implement v1 CLI tools (properties, tasks, links, daily note category)

Add 4 new category-based CLI tools for the Obsidian CLI integration:
- obsidianDailyNote: read, append, prepend, path (replaces v0 obsidianDailyRead)
- obsidianProperties: list vault properties, read per-note properties
- obsidianTasks: list and filter tasks across vault
- obsidianLinks: backlinks, outgoing links, orphans, unresolved links

All tools are desktop-only (gated by Platform.isDesktopApp), invisible on
mobile, and labeled as "Obsidian CLI: ... (Experimental)" in settings.

Includes shared error handling module (cliErrors.ts), reasoning summaries,
tool display names, disambiguation prompt instructions, and 21 new tests.

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

* fix: prioritize env CLI binaries and align desktop runtime check

- resolveBinaryCandidates: move env var overrides (OBSIDIAN_CLI_BINARY/
  OBSIDIAN_CLI_PATH) before the default "obsidian" binary so configured
  overrides are tried first, preventing an incompatible PATH binary from
  blocking a valid configured override.
- builtinTools: replace Platform.isDesktopApp with isDesktopRuntime()
  (now exported) so CLI tools are registered on both modern isDesktopApp
  and legacy isDesktop runtimes, matching the check used in the executor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: single CLI master toggle, remove v0 overlap, extract buildCliParams

- Group all CLI tools under one "Obsidian CLI (Experimental)" section
  with a single master toggle in settings UI
- Remove v0 obsidianDailyRead registration (superseded by v1 obsidianDailyNote)
- Extract buildCliParams helper to reduce repetitive param building
- Use "cli" category for all CLI tool registrations

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

* fix: align CLI section title with other tool items

Remove horizontal padding from CLI container box so SettingItem
title aligns flush with Vault Search, Web Search, etc. above it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: preserve raw stdout for daily:read to avoid stripping Markdown whitespace

Only trim stdout for non-read commands (append, prepend, path) where
whitespace is not semantically meaningful. Read commands return note content
where leading/trailing whitespace may be intentional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add daily create command and obsidianTemplates tool to v1 CLI

- Add 'daily' command to obsidianDailyNote: creates today's daily note
  from configured template if it doesn't exist
- Add obsidianTemplates category tool with 'templates' (list) and
  'template:read' (read content) commands
- Register obsidianTemplates in builtinTools.ts with cli category
- Update AgentReasoningState and toolExecution with new tool/command cases
- Update obsidianDailyNote prompt instructions to mention 'daily' command
- Move obsidianTemplates from v2 to v1 in design doc; add daily command docs
- Add tests for daily command and full obsidianTemplates tool coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove daily command, keep only obsidianTemplates addition

The 'daily' CLI command is a UI action (opens the note in Obsidian) and
returns no content, making it unsuitable as an agent tool. Remove it.

Instead, the agent workflow for template-based daily note creation is:
  obsidianTemplates template:read → daily:read (check existence) →
  daily:prepend (write template content if needed)

Update obsidianDailyNote prompt instructions to describe this pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: update CLI tool prompt instructions for template-based daily note workflow

- obsidianDailyNote: add step-by-step guide for creating daily notes from
  templates (templates list → template:read → daily:prepend); note that
  daily:append/prepend auto-create the note if it doesn't exist
- obsidianTemplates: clarify usage of 'templates' and 'template:read',
  cross-reference the daily note creation workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: preserve raw stdout for read commands in ObsidianCliDailyTools

Same fix as d2c81b5 for ObsidianCliTools: daily:read and random:read
commands return full markdown note content, so trimming stdout can silently
alter formatting-sensitive notes. Both tools now return raw stdout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:40:07 -08:00
Emt-lin
11f3878244
feat: refactor GitHubCopilotChatModel to support tool calling (#2242)
* feat: refactor GitHubCopilotChatModel to support tool calling

Refactored GitHubCopilotChatModel from BaseChatModel to ChatOpenAICompletions
to enable bindTools() for Agent Mode tool calling support.

Key changes:
- Extend ChatOpenAICompletions instead of BaseChatModel for native tool support
- Override _streamResponseChunks to fix Copilot-specific streaming issues:
  1. Missing delta.role when proxying Claude models (defaults to "assistant")
  2. Non-string delta.content arrays from Claude (normalized to string)
- Use public convertCompletionsDeltaToBaseMessageChunk API (not deprecated method)
- Inject Copilot auth via configuration.fetch wrapper with 401/403 retry
- Export COPILOT_API_BASE and add public token management wrappers

* fix: improve type safety and retry logic in GitHubCopilotChatModel

- Replace loose `[key: string]: any` params interface with type derived
  from ChatOpenAICompletions constructor fields for proper type checking
- Limit auth retry to 401 only (not 403) to match GitHubCopilotProvider
  behavior and avoid useless token refresh on permanent permission errors
- Replace 80-line _streamResponseChunks fork with minimal
  _convertCompletionsDeltaToBaseMessageChunk override
- Remove unused imports (BaseChatModelParams, AIMessageChunk, etc.)

* refactor: remove dead code from GitHubCopilotProvider after ChatOpenAICompletions migration

Chat completions are now handled by GitHubCopilotChatModel (extends
ChatOpenAICompletions) with auth injected via configuration.fetch.
The old Provider-level request methods and their supporting types
are no longer referenced anywhere.

Removed:
- sendChatMessage, sendChatMessageStream, executeWithTokenRetry methods
- CopilotChatResponse, CopilotStreamChunk, CopilotRequestOptions interfaces
- CHAT_COMPLETIONS_URL, HTTP_STATUS_MESSAGES constants
- Unused imports: createParser, ParsedEvent, ReconnectInterval, FetchImplementation

* feat: add model policy terms UI, update Copilot headers, and fix review issues

- Update Copilot request headers to latest VSCode versions
- Cache model policy terms and surface activation guidance on 400 errors
- Extend GitHubCopilotModel interface with billing/policy/endpoint fields
- Show verification error with parsed Markdown links in ModelImporter
- Fix unused logInfo import, guard Request instanceof check,
  case-insensitive "not supported" match, clear policy cache on resetAuth
2026-03-05 09:46:53 -08:00
Wenzheng Jiang
f51036fa8a
Add license auth header to Miyo requests (#2260)
* Add license auth header to Miyo requests

* Remove unused self-host API key setting

* Keep self-host API key in settings

* Log Miyo auth header presence
2026-03-05 07:37:51 +09:00
Logan Yang
adbb646729
Add release agent definition (#2258)
* Add release agent definition for automated release PR creation

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

* fix: address Codex review comments on release agent

- Add package-lock.json to staged files in Step 7 (npm version also
  updates it, so omitting it leaves a dirty worktree after commit)
- Raise PR list limit from 100 to 500 and add note about pagination
  to avoid silently truncating changelogs with many merged PRs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:06:03 -08:00
Logan Yang
0fc5d33e25
3.2.4 (#2259)
* release: v3.2.4

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

* release: add detailed changelog to v3.2.4 notes

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:26:25 -08:00
Logan Yang
e3f57e0dfd
ci: add automated release workflow on PR merge (#2256)
* ci: add automated release workflow on PR merge

Triggers when a PR targeting master is merged with a strict semver title
(e.g., "3.2.3"). Builds the plugin and creates a GitHub Release with
main.js, manifest.json, and styles.css attached. Non-semver PR titles
are silently skipped. Includes manifest.json version match validation
and shell injection protection for release notes.

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

* fix: pin release checkout to merge commit SHA

Avoids race condition where a concurrent PR merge could cause the release
workflow to build a different commit than the one that triggered it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: prevent shell injection in release workflow and add idempotency check

Use env vars instead of inline ${{ }} expansion for PR title and version
outputs to prevent shell injection. Add pre-check to skip release creation
if the version tag already exists.

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

* fix: pin release tag to merge commit SHA via --target flag

Ensures gh release create tags exactly the merge commit, preventing
a source/binary mismatch if concurrent merges land before the step runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:37:02 -08:00
Logan Yang
fcac972bb2
fix: use safeFetch for Copilot Plus to bypass browser TLS errors (#2255)
Browser's native fetch() fails with net::ERR_CERT_AUTHORITY_INVALID when
calling models.brevilabs.com. Using safeFetch routes through Obsidian's
requestUrl (Node/Electron layer), bypassing browser-level TLS validation.

Fixes #2250

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:03:39 -08:00
Logan Yang
d7d54d2a34
docs: add user-facing documentation (#2254)
* docs: add user-facing documentation (closes #2253)

- 13 docs covering all major features: getting started, chat interface,
  LLM providers, models/parameters, context/mentions, custom commands,
  vault search/indexing, agent mode/tools, projects, system prompts,
  Copilot Plus/self-host, troubleshooting/FAQ, and index
- Written for non-technical Obsidian users
- Each doc is standalone with cross-references to related docs
- All values verified against source code (defaults, model names, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: improve user docs with website reference material

- getting-started: add glossary (LLM, API, Token, Context Window,
  Embeddings, RAG, Vector Store) and API key billing note
- chat-interface: add user/AI message buttons (edit, copy, delete,
  insert/replace at cursor, regenerate), [[Note Title]] inline reference
  syntax, Relevant Notes feature, manual Save Chat button, and
  auto-compact user experience note
- context-and-mentions: add PDF context (+ Add context button) and
  image context (drag/drop or image button) methods
- custom-commands: fix placeholder syntax ({} not {selected text}),
  add {FolderPath} variable, note tags must be in note properties,
  add richer example prompts
- llm-providers: add LM Studio CORS requirement, 3rd-party CORS warning
- vault-search-and-indexing: add cost estimation tip (Count total
  tokens command), RangeError/partitioning note, tag property note
- agent-mode-and-tools: add Revert option, note @composer works in
  both Plus and Projects modes
- projects: add 50+ file type support detail
- copilot-plus-and-self-host: add dashboard URL
- troubleshooting-and-faq: add first-steps section, RangeError fix,
  response cut-off fix, notes-not-found checklist, note referencing
  FAQ, English response FAQ, image/PDF FAQ, privacy note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove UI_RENDERING_PERFORMANCE.md from docs (already in designdocs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add doc maintenance rule to CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: fix custom command variable syntax and project deletion claim

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: clarify 16 built-in providers + unlimited OpenAI-compatible models

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: fix semantic search default and partitioning claims

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: fix self-host status and remaining partitioning claim

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: fix self-host setup steps, tool count, and memory tool availability

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 17:39:00 -08:00
Logan Yang
fff6b4efd0
chore: rename docs to designdocs and nest todo folder (#2252)
- Renamed docs/ to designdocs/
- Moved draft/todo docs into designdocs/todo/ subfolder
- Added OBSIDIAN_CLI_INTEGRATION.md from master's todo/ folder
- Updated all docs/ path references in CLAUDE.md, AGENTS.md, and designdocs

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:26:03 -08:00
Logan Yang
97763aaf93
feat(chat-history): add infinite scroll pagination to ChatHistoryPopover (#2251)
* feat(chat-history): add infinite scroll pagination to ChatHistoryPopover

Progressively loads chat history in batches of 50 via IntersectionObserver
on a sentinel div, preventing UI lag for users with 1000+ conversations.

- Observer is created once on mount with empty deps; reads live pagination
  state via paginationStateRef — no disconnect/reconnect as pages load or
  search filters change
- Reset only fires when popover opens or search changes while open (not close)
- Status indicator is plain text (data is local/synchronous, not async)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(chat-history): use useLayoutEffect to prevent pagination reset render spike

Replace useEffect with useLayoutEffect for the displayCount reset so it
runs synchronously before the browser paints, eliminating the one-frame
render spike that occurred when reopening the popover with a large
displayCount from a previous scroll session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add UI rendering performance audit report

Comprehensive audit of React rendering inefficiencies across the chat UI,
state management, and streaming paths. 14 findings ranked by severity
(Critical/High/Medium/Low) with recommended fix priorities.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:06:54 -08:00
Emt-lin
86da5e9e61
fix: chat panel table rendering and third-party plugin compatibility (#2226)
Load the Component used for MarkdownRenderer so that post-processor
children (e.g. advanced-table-xt SheetElement) can execute their
onload() lifecycle. Also add table border/padding styles to
.message-content to match Obsidian's native table appearance.
2026-03-03 00:13:03 -08:00
Logan Yang
f0a822ca32
fix: upgrade @langchain/google-genai to fix Gemini streaming crash (#2249)
Upgrade @langchain/google-genai from 1.0.2 to 2.1.23 to fix a crash
where `candidateContent.parts.reduce()` throws when `parts` is undefined
during Gemini streaming responses. The new version uses optional chaining
(`candidateContent.parts?.reduce()`).

Also upgrades @langchain/core (1.1.13 → 1.1.29) and langchain (1.2.8 →
1.2.28) to align peer dependencies.

Fixes #2248

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:03:37 -08:00
Logan Yang
2e05af916f
fix: prevent silent agent loop termination with Gemini (#2233) (#2247)
Root cause: Gemini's @langchain/google-genai nests tool call names inside
`functionCall.name` instead of at the top-level `name` property. Our
streaming accumulator only checked `tc.name`, so all Gemini tool call
names were silently dropped. `buildToolCallsFromChunks` then skipped
nameless entries, causing the agent loop to treat the response as
"no tool calls" and return empty content (thinking tokens were filtered).

Changes:
- Extract `accumulateToolCallChunk` into nativeToolCalling.ts with
  `functionCall.name` fallback via nullish coalescing
- Add empty response detection with diagnostic logging
- Rewrite tests to cover real code paths with both Gemini-format and
  OpenAI-format tool call chunks

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:43:58 -08:00
Logan Yang
4b81f0dae5
fix(ui): improve system prompt template syntax hints (#2235) (#2245)
Add placeholder text and syntax instructions to clarify that note
references in system prompts require {[[Note Name]]} syntax (curly
braces), since bare [[Note Name]] wikilinks are not resolved.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:58:29 -08:00
Logan Yang
ee8fd5779e
fix(settings): allow resetting default system prompt to built-in (#2246)
Add a "None (use built-in prompt)" option to the system prompt dropdown
so users can deselect a custom system prompt and revert to the default.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:57:50 -08:00
Logan Yang
2bab7acb05
fix: normalize string booleans in localSearch schema for Qwen 3.5 (#2232) (#2243)
Qwen 3.5 (LM Studio) emits returnAll as string "True"/"False" instead
of boolean, causing Zod schema validation to fail. Add z.preprocess()
to coerce string booleans to actual booleans before validation.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:38:51 -08:00
Wenzheng Jiang
f67e835afb
feat: add custom Miyo server URL setting for remote deployments (#2229)
* feat: add custom Miyo server URL setting for remote deployments

Introduces a miyoServerUrl setting that lets advanced users point Copilot
at a Miyo instance running on a remote machine. When blank, automatic local
service discovery is used as before. Also fixes a crash (undefined.trim())
that occurred when existing saved settings lacked the new field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update Miyo test mocks to use miyoServerUrl and getMiyoCustomUrl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:29:43 +09:00
Michael Ryan
d45dae883a
Add webTabs support to ChatPersistenceManager test "should preserve context information through save and load cycle" (#2239) 2026-03-02 18:19:44 -08:00
Wenzheng Jiang
565969cada
feat: add confirmation dialog before clearing Miyo index (#2211) 2026-03-03 11:11:13 +09:00
Logan Yang
f19ebdf4f9
fix(ui): prevent stream usage row from crowding add model actions (#2234) 2026-03-02 17:01:26 -08:00
Wenzheng Jiang
edbe59901f
fix: skip redundant eligibility check when enabling Miyo (#2228)
The Miyo toggle is only visible when Self-Host Mode is already enabled,
which requires passing validateSelfHostMode() first. Calling it again
when enabling Miyo was redundant and could incorrectly block users who
are offline after their grace period expires mid-session.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 11:10:42 +09:00
Logan Yang
17c6714423
fix: restore Ctrl+Enter and Ctrl+Shift+Enter shortcuts in command modal (#2220)
The keyboard shortcuts for Replace (Ctrl/Cmd+Enter) and Insert
(Ctrl/Cmd+Shift+Enter) were lost during the Quick Ask refactor. This
restores them with proper popout-window and multi-modal scoping.

Fixes #2219

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:16:13 -08:00
Emt-lin
1415aabdb0
fix: prevent stale selected text from leaking into L2 context (#2222)
When "Auto-Add Selection to Context" was used, changing the selection
and sending a new message would still show the old selection to the LLM.
The root cause was that selected_text / web_selected_text segments from
previous turns were promoted into L2 context verbatim, shadowing the
current turn's fresh context.

Fix: in compactSegmentForL2(), strip non-recoverable blocks (derived
from contextBlockRegistry) and prior_context_note before compacting.
Uses a "remove blacklist" approach that preserves unknown tags and
inter-block text. Also tighten hasCompactedContent detection to require
the source= attribute, avoiding false-positives from literal XML in
user note content.
2026-02-28 14:15:40 -08:00
Emt-lin
2fe2332fe4
fix: close Radix portaled layers when mobile drawer hides (#2223)
* fix: close Radix portaled layers when mobile drawer hides

Radix popover/dropdown portals to document.body, causing them to jump
to (0,0) when the mobile drawer hides and the anchor element disappears.

- Add hideWhenDetached on PopoverContent and DropdownMenuContent for mobile
- Observe drawer is-hidden class change and dispatch Escape to close state
- Add cancelable: true to synthetic Escape for correct preventDefault semantics

* fix: reattach drawer hide observer when view moves between containers

The drawer hide observer was bound once during onOpen, but the view can
move between containers without onOpen firing again. Listen for
layout-change events to re-bind the observer to the current drawer.
2026-02-28 14:14:19 -08:00
Logan Yang
efda62a6ac
fix: replace Node.js Buffer with cross-platform TextEncoder for mobile indexing (#2216)
Buffer.byteLength is unavailable on Obsidian mobile (Android/iOS WebView),
causing indexing to fail with "Buffer is not defined". Use the existing
MemoryManager.getByteSize() helper which uses TextEncoder instead.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:01:51 -08:00
Logan Yang
871f66bddf
docs: add release notes for v3.2.2 and v3.2.3 (#2215)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:33:31 -08:00
Logan Yang
c464b33cae
3.2.3 (#2214)
* 3.2.3

* fix: rename "Copy command" to "Duplicate command" with copy-plus icon (#2150)

Improves UX clarity by distinguishing the duplicate action from clipboard copy.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:11:04 -08:00
Logan Yang
f086ad76dc
fix: use safeFetchNoThrow in BrevilabsClient to bypass CORS errors (#2213)
Replace native fetch() with safeFetchNoThrow() in makeRequest() so API
calls route through Obsidian's requestUrl (Electron/Node layer) instead
of Chromium's network stack, eliminating intermittent CORS failures.

makeFormDataRequest retains native fetch() because safeFetch hardcodes
Content-Type: application/json and calls body.toString(), which breaks
FormData payloads.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:34:43 -08:00
Michael Ryan
9b9bf151c1
Remove emoji from FAQ link url in table of contents (#2209) 2026-02-23 20:16:24 -08:00
Logan Yang
6fb63401ec
feat: configurable output folder for converted docs + Miyo PDF routing (#2210)
Add a user-configurable setting to save converted document markdown
(from pdf4llm/docs4llm) to a specified vault folder. Previously,
converted content was only stored in internal cache with opaque names.

Also routes PDFs through Miyo in project mode when self-host is enabled,
preserving privacy by never falling back to cloud APIs in self-host mode.

Changes:
- New "Store converted markdown at" setting under Document Processor
  (empty = disabled, suggested: copilot/converteddocs)
- Flat file naming ({basename}.md) with double-underscore disambiguation
  for collisions, source-header traceability, and content-dedup to avoid
  mtime churn
- Export on cache hits so enabling the setting retroactively works
- SelfHostPdfParser in Docs4LLMParser for Miyo-first PDF parsing in
  project mode
- MiyoParseResult type (null | {content} | {error}) propagates detailed
  Miyo errors to the user instead of generic failure messages
- Self-host privacy: PDF parse failures return an error with details
  rather than silently uploading to cloud API
- TODO comments for request timeout, batch progress feedback

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:15:54 -08:00
Wenzheng Jiang
19314a95bb
fix: log Miyo health check status and remove debug guard on availability failure (#2212)
When Miyo returns a non-"ok" status (e.g. "degraded"), isBackendAvailable
silently returned false with no log, making users think the health check
passed while still seeing the "Miyo app is not available" notification.

Now logs the actual status unconditionally so users can diagnose why
availability check failed. Also removes the debug-only guard on the
catch block so network errors are always surfaced.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 17:45:10 -08:00
Wenzheng Jiang
c6d6a5201d
Add cross-platform Miyo service discovery with Windows Roaming fallback (#2208)
* feat: add cross-platform Miyo service discovery fallbacks

* fix: avoid caching fallback Miyo discovery URL
2026-02-23 10:01:11 -08:00
Logan Yang
2fd6412942
fix: update "upcoming desktop app" language to "our desktop app Miyo" (#2207)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:31:11 -08:00
Logan Yang
81015f6e7b
3.2.2 (#2206) 2026-02-22 23:05:31 -08:00
Logan Yang
0b6bb5a7bf
fix: support hidden directory chat save and history (#2188) (#2204)
When defaultSaveFolder is a hidden directory (e.g. .copilot/conversations),
Obsidian's metadata cache doesn't index it, causing saves to fail and
chat history to show empty.

Add adapter-level fallback utilities in src/utils/vaultAdapterUtils.ts:
- resolveFileByPath: vault lookup with synthetic TFile fallback
- listMarkdownFiles: folder listing with adapter fallback
- patchFrontmatter: processFrontMatter with adapter YAML patching fallback
- readFrontmatterViaAdapter: parse frontmatter from raw file content

Apply fallbacks across ChatPersistenceManager (save, load, list, find)
and main.ts (history picker, load, delete, title update, lastAccessedAt).

Also removes obsolete design docs from docs/.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:01:59 -08:00
Wenzheng Jiang
4439ed9916
fix: address Miyo PR review findings (privacy, dedup, architecture) (#2191)
* fix: address Miyo PR review findings (privacy, dedup, architecture)

- Gate request body logging behind debug flag to prevent note content
  leaking to vault log files on every upsert (P1 privacy fix)
- Replace duplicated grace period logic in findRelevantNotes with
  isSelfHostAccessValid() from plusUtils (P1 dedup fix)
- Spread-copy metadata in fromMiyoDocument to avoid mutating API response
- Remove dead if(enabled) branch in CopilotPlusSettings toggle handler
- Gate per-document upsert log lines behind debug to prevent log churn
- Rename misleading test; add no-embeddings fallback path test coverage
- Add TODO for cross-platform service discovery paths (Windows/Linux)
- Use this.client directly in MiyoSemanticRetriever.getExplicitChunks
  instead of VectorStoreManager singleton to avoid backend mismatch
- Remove unused apiKeyOverride param from buildHeaders; document dual auth
- Read pagination total only from first response in getIndexedFiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address PR review comments on Miyo retriever and index backend

- Guard response.documents with ?? [] in getExplicitChunks to prevent
  crash when backend omits the field or parseResponseJson returns {}
- Remove redundant getSettings().debug guard around logInfo calls since
  the logging utilities already respect the debug flag internally
- Document the debug-flag behavior in AGENTS.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: remove explicit path reads from Miyo semantic retriever

* fix: rename remaining enableMiyoSearch references

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 11:21:53 -08:00
Logan Yang
85192a3005
Rename enableMiyoSearch to enableMiyo and scope Miyo to Plus/agent chains (#2201)
- Rename `enableMiyoSearch` → `enableMiyo` across settings, UI, and all consumers
- Skip fulltext search in performLexicalSearch (Plus/agent path) when Miyo is active;
  filterRetriever still runs for tag/title matching
- VaultQA chain now passes `{ enableMiyo: false }` to RetrieverFactory so it never
  routes through Miyo
- Expose `RetrieverFactory.isMiyoActive()` for clean Miyo-state checks

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:27:54 -08:00
Wenzheng Jiang
ea552077d9
Add Miyo document parsing toggle and PDF parse-doc integration (#2199)
* feat: add miyo document parsing toggle for PDF parser

* refactor: consolidate miyo toggles into single enable flag
2026-02-21 20:11:11 -08:00
Logan Yang
30b54b6fdd
fix: Gemini 3 preview models image support (#2197)
* fix: upgrade @langchain/google-genai to fix Gemini 3 preview image support (#2178)

The @langchain/google-genai v1.0.0 had a hardcoded _isMultimodalModel check
that only recognized gemini-1.5, gemini-2, and "vision" model names. Gemini 3
preview models (gemini-3-flash-preview, gemini-3-pro-preview) were rejected
with "This model does not support images" when sending image content.

Upgrade to v1.0.2 which adds gemini-3 to the multimodal model pattern.
Also bump @google/generative-ai from ^0.21.0 to ^0.24.0 (required dep).

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

* fix: update built-in Gemini Pro to 3.1 preview model IDs

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:10:03 -08:00
Wenzheng Jiang
0812ca3e40
Miyo indexing: reset batch size on enable and apply updates after resume (#2198)
* Reset embedding batch size to default when enabling Miyo search

* Apply updated embedding batch size after indexing resume

* Fix indexing progress total after pause/resume
2026-02-20 08:31:18 -08:00
Logan Yang
c7fa37f69c
feat: add Firecrawl and Supadata self-host support for web search and YouTube (#2196)
* feat: add Firecrawl and Supadata self-host support for web search and YouTube

Self-host mode users can now use their own API keys for web search (Firecrawl)
and YouTube transcripts (Supadata) instead of routing through the Brevilabs
backend. Tools gracefully fall back to Brevilabs when no self-host key is set.

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

* feat: add Perplexity Sonar as alternative self-host web search provider

Add provider dispatch pattern for self-host web search, supporting both
Firecrawl and Perplexity Sonar via a dropdown selector in settings UI.

Changes:
- Refactor selfHostWebSearch into provider dispatch (firecrawl/perplexity)
- Add hasSelfHostSearchKey() helper for provider-agnostic key checks
- Add settings UI dropdown for web search provider selection
- Fix Mention.ts YouTube transcription to route via Supadata in self-host mode
- Fix cached failed URL results never being retried (retry-on-error)
- Clear Miyo Search state when self-host mode is disabled
- Add 19 unit tests for selfHostServices

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:44:39 -08:00
Logan Yang
5ea26b1b7b
Add acp design doc (#2195) 2026-02-19 16:11:56 -08:00
Logan Yang
2da3fa8574
feat: self-host web search (Firecrawl) + YouTube (Supadata) (#2190)
* feat: add Firecrawl and Supadata self-host support for web search and YouTube

Self-host mode users can now use their own API keys for web search (Firecrawl)
and YouTube transcripts (Supadata) instead of routing through the Brevilabs
backend. Tools gracefully fall back to Brevilabs when no self-host key is set.

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

* refactor: drop legacy Perplexity wrapper from self-host web search

selfHostWebSearch now returns { content, citations } directly instead of
wrapping results in the OpenAI-chat-completion shaped WebSearchResponse
that Brevilabs inherited from Perplexity. SearchTools unwraps each path
independently.

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

* refactor: move Miyo Search under self-host conditional and improve description

Miyo Search toggle is now only visible when self-host mode is enabled,
grouped alongside Firecrawl and Supadata settings. Updated description
to be less technical and highlight vault size advantage.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 23:23:39 -08:00
Logan Yang
795ed5ff6f
fix: hard cap L1 project context + payload diagnostics (#2192)
* docs: add token budget enforcement analysis and fix plan

Document root cause of context window overflow (2.7M tokens sent to 1M
model): L4 chat history bypasses all compaction systems. Add fix plan
with phased approach to enforce token budget at message assembly point.

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

* docs: correct root cause analysis — L1 unbudgeted, not L4

Previous analysis incorrectly blamed L4 chat history as the primary
culprit. Investigation shows L4 stores only bare L5 text + compacted
responses. The real issue is systemic: no total payload enforcement,
with L1 (project context) being the largest unbudgeted layer and
PROJECT_COMPACT_THRESHOLD being blind to L1 size.

Updated fix plan to be model-agnostic (use autoCompactThreshold as
single budget, no model-specific lookup tables), added contextTurns
deprecation, and history guarantee principle.

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

* fix: hard cap L1 project context at 600k tokens + payload diagnostics

- Truncate project context at 2.4M chars (~600k tokens) to prevent
  total payload from exceeding model context windows (temporary fix
  until full token budget enforcement is implemented)
- Add per-layer token estimate logging when payload exceeds 2M chars
  to help diagnose context window overflow reports
- Document Obsidian CLI dev tools in CLAUDE.md

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:25:56 -08:00
Wenzheng Jiang
dc729fdba4
Miyo integration: backend + retriever (#2156)
* feat: integrate miyo search backend

* fix: avoid node builtins in miyo discovery

* chore: omit model name for miyo embeddings

* Revert "chore: omit model name for miyo embeddings"

This reverts commit 9d1622b7d8bd8243b5b2e301e356a42fd0c934de.

* Add Miyo debug logging for search and upsert

* Include vault path hash in Miyo source_id

* Lock Miyo embedding model in settings UI

* Update Miyo collection naming and search payloads

* Implement Miyo garbage collection

* Add Miyo time-range filters

* Remove Miyo embeddings and move Miyo search toggle

* Revert "Remove Miyo embeddings and move Miyo search toggle"

This reverts commit b0b787103561459dbd9128f6a96dad3d2914e6c9.

* refactor miyo indexing integration

* fix: resolve build type import and remove Miyo API requirement doc

* Add Miyo related-notes endpoint support for Relevant Notes

* Refine Miyo search toggle availability and indexing flow

* Update Miyo API fields and streamline relevant-notes Miyo flow
2026-02-18 18:01:18 -08:00
Logan Yang
edd1e5f00e
Fix indexing crash from ghost IDs in internalDocumentIDStore (#2182)
* Fix "Cannot set properties of undefined (setting 'embedding')" during indexing

Rebuild internalDocumentIDStore after loading partitioned chunks to eliminate
ghost IDs from removed documents that cause position mismatches. Also add
missing nchars field to Orama schema to match the OramaDocument interface.

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

* Remove Notice popup spam for per-file indexing errors

Errors during indexing (e.g. "Semantic index database not found" when DB
isn't loaded yet) were triggering a Notice popup for every file event,
flooding the user with popups. Errors are already logged to console and
surfaced in the in-chat IndexingProgressCard.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:25:40 -08:00
Logan Yang
ff27c472a4
Fix unwanted indexing + editor lag during indexing (#2189)
* Fix unwanted indexing when semantic search disabled + editor lag during indexing

Add defense-in-depth enableSemanticSearchV3 guard to both VectorStoreManager
and IndexOperations to prevent indexing when semantic search is off (fixes
plusUtils.applyPlusSettings bypass). Throttle Jotai atom updates to 500ms
intervals during indexing to reduce React re-renders. Yield to main thread
between batches to keep the editor responsive.

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

* Remove yieldToMainThread — existing awaits already yield to the event loop

The batch loop already has multiple await points (rateLimiter.wait,
embedDocuments, upsert, save) that yield to the main thread. The explicit
setTimeout(0) was redundant and added no user-perceived benefit.

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

* Guard throttled callback against stale indexing sessions

Cancel pending throttle timer in resetIndexingProgressState() so a
trailing write from a previous (failed/aborted) run cannot overwrite
the freshly-reset atom state of a new run. Also reorder declarations
so throttle variables are defined before the function that references them.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:24:38 -08:00
Zero Liu
875cc0a34e
refactor: move badge close button to left icon overlay on hover (#2183)
* refactor: move badge close button to left icon overlay on hover

Centralize the icon + X overlay pattern in ContextBadgeWrapper instead of
copy-pasting the X button across all 8 badge types. The X now appears on
hover over the left icon position using invisible/visible toggling for
zero layout shift.

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

* support mobile

* fix keyboard control

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:12:18 -08:00
Wenzheng Jiang
ce8f406337
Fix tiny heading-only search chunks via coalescing (#2184) 2026-02-12 23:48:41 -08:00
wotan-allfather
b218dbf5b5
feat: add streamUsage config for 3rd party OpenAI-format providers (#2148)
Add optional streamUsage setting to CustomModel interface that allows users
to control whether stream_options is sent to the API. This fixes compatibility
with 3rd party providers (e.g., Databricks, MLFlow) that do not support the
stream_options parameter.

- Add streamUsage?: boolean to CustomModel interface in aiParams.ts
- Pass streamUsage to ChatOpenAI config for OPENAI_FORMAT and LM_STUDIO providers
- Add UI toggle in ModelAddDialog and ModelEditDialog with helpful tooltip
- Default to false to maintain compatibility with providers that error on stream_options

Closes #2046
2026-02-10 21:30:29 -08:00
Logan Yang
05831e345d
3.2.1 (#2177)
* Add v3.2.1 release notes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* 3.2.1

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 20:48:05 -08:00
Logan Yang
c9b4042969
Fix ENAMETOOLONG error in Composer writeToFile tool (#2176)
Sanitize LLM-generated file paths before creating files to prevent
ENAMETOOLONG crashes on Linux (ext4 255-byte filename limit). Truncates
overlong basenames while preserving file extensions and multi-byte
character boundaries. Sanitization happens at the tool entry point so
the preview flow (ApplyView) also receives the safe path.

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 20:24:43 -08:00
Logan Yang
9535bf5a74
Replace indexing Notice with in-chat progress card & fix phantom re-indexing (#2173)
* Replace indexing Notice with in-chat progress card & fix phantom re-indexing

Replace the Obsidian Notice popup for indexing progress with a persistent
in-chat progress card using the same Jotai atom pattern as project mode.

Key changes:
- New IndexingProgressCard component with progress bar, pause/resume/stop,
  error display, and auto-close on completion
- Jotai atom (indexingProgressAtom) driven by IndexOperations, read by React
- Card only appears when files actually need indexing (no flash on mode switch)
- User-initiated actions (refresh/reindex buttons) show "Index Up to Date"
  feedback with green check icon
- Fix phantom re-indexing: checkIndexIntegrity() is now diagnostic-only
- Fix progress bar accuracy: totalFiles counted after chunk preparation
- Remove duplicate completion Notices from ChatControls and RelevantNotes

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

* Fix insert/replace at cursor including agent reasoning blocks (#2174)

Strip <!--AGENT_REASONING:...--> markers in cleanMessageForCopy() so
that "Insert / Replace at cursor" only inserts the actual AI response,
not the internal agent reasoning metadata.

Uses greedy .* (single-line) so the regex matches to the real closing
--> even if the JSON payload contains that sequence.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Restore integrity check marking files for re-indexing

checkIndexIntegrity() was made diagnostic-only to fix phantom
re-indexing, but this left files with missing embeddings permanently
skipped. The real safeguard is clearFilesMissingEmbeddings() at the
start of each indexing run, which prevents the repeat cycle. Restore
the mark so the next indexing trigger picks up integrity failures.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:08:43 -08:00
Logan Yang
3e323ba8c8
Fix insert/replace at cursor including agent reasoning blocks (#2174)
Strip <!--AGENT_REASONING:...--> markers in cleanMessageForCopy() so
that "Insert / Replace at cursor" only inserts the actual AI response,
not the internal agent reasoning metadata.

Uses greedy .* (single-line) so the regex matches to the real closing
--> even if the JSON payload contains that sequence.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:54:52 -08:00
Logan Yang
6e6ada0431
Fix local search: simplify pipeline, extract FilterRetriever, fix grep filtering (#2172)
* Simplify search pipeline: remove expandedTerms, use metadata-cache tag matching

- Remove `expandedTerms` concept from QueryExpander, FullTextEngine, SearchCore,
  and all interfaces — LLM expansion now only produces `salientTerms` and
  `expandedQueries`, no separate related-term list
- Remove weighted 90/10 two-phase scoring from FullTextEngine; use single BM25+
  search with salient terms for ranking
- Replace tag-based `returnAll` mode with Obsidian metadata-cache tag matching:
  `getTagMatches()` uses `getAllTags()` with hierarchical prefix matching and
  injects tag-matched notes as guaranteed full-note documents (score 1.0)
- Remove `buildTagRecallQueries()` tag-hierarchy splitting from SearchCore
- Add `isGrepWorthy()` filter in GrepScanner to skip short ASCII terms (< 3 chars)
- Reduce default candidateLimit and grepLimit from 500 to 200
- Rewrite search v3 README to reflect current implementation
- Update all tests for removed fields and new tag-match injection behavior

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

* Fix isGrepWorthy filtering out valid 2-char terms like "AI", "ML", "UI"

SearchCore lowercases all recall terms before passing to GrepScanner, so
acronym detection by casing is impossible. Lower the threshold to filter
only single-char terms — grep is capped at 200 results anyway, and BM25+
handles precision downstream.

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

* Extract FilterRetriever from TieredLexicalRetriever for guaranteed filter matching

Move deterministic title/tag/time-range matching into standalone FilterRetriever
class that runs at the orchestration layer (SearchTools, VaultQAChainRunner,
customSearchDB). This ensures filter results bypass all downstream retriever
processing (top-K slicing, score normalization) and reach the LLM context via
separate <filterResults>/<searchResults> XML sections with continuous ID numbering.

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

* Cap FilterRetriever tag and time-range results to maxK/RETURN_ALL_LIMIT

Prevents unbounded filter result sets (e.g. common tags like #daily) from
blowing up prompt/context size and read latency.

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

* Clean up legacy parser, dead code, and DRY violations

- Remove parseLegacyFormat from QueryExpander (fall back to fallbackExpansion for non-XML LLM responses)
- Remove dead fuzzy variant generation in fallbackExpansion
- Remove deprecated expandQueries backward-compat method
- Extract shared mapDoc helper in SearchTools to eliminate duplicated document mapping
- Use existing toIsoString helper in formatSplitSearchResultsForLLM
- Add FilterRetriever cap regression tests for maxK and RETURN_ALL_LIMIT
- Convert legacy-format tests to XML format

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

* Restore expanded limits for tag queries in VaultQAChainRunner

Pass returnAll: true when tagTerms are present so FilterRetriever
and the main retriever use RETURN_ALL_LIMIT instead of maxK cap.

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

* Cap merged filter+search results to prevent oversized prompts

Filter and search results are now capped post-merge (filter results
prioritized) to prevent 100-200 doc payloads on tag-heavy queries.

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

* Add retrieval cap notice in VaultQA to prompt Copilot Plus upgrade

When merged filter+search results exceed the retrieval cap, the AI
is instructed to inform the user that some documents were omitted
and suggest upgrading to Copilot Plus for more complete answers.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 16:01:55 -08:00
Logan Yang
c2fe511eb8
3.2.0 (#2170)
* Add RELEASES.md with full release history and v3.2.0 draft

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

* 3.2.0

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:54:44 -08:00
Logan Yang
3de16740ec
Show Self-Host Mode section to all users with disabled toggle (#2169)
* Show Self-Host Mode section to all users with disabled toggle for non-lifetime

The Self-Host Mode settings section is now visible to everyone instead of
being hidden behind an eligibility check. The toggle is disabled (greyed out)
for users who are not on a lifetime plan (believer/supporter), making the
feature discoverable while preserving the access restriction.

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

* Fix self-host toggle not disabled after removing license key

Three issues fixed:
1. useIsSelfHostEligible() checked grace period before license key,
   so removing the key didn't revoke eligibility while in grace period.
   Moved license key check to the top and added auto-revocation of
   enableSelfHostMode when key is absent.
2. isSelfHostModeValid() didn't check license key at all, so backend
   code (RetrieverFactory, isPlusEnabled) continued to grant self-host
   access after key removal. Added license key guard.
3. useIsPlusUser() self-host bypass didn't check license key. Added
   the same guard for consistency.

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

* Fix self-host toggle not disabled when switching to non-lifetime key

useIsSelfHostEligible() trusted cached grace period / permanent
validation before consulting the API, so swapping from a believer
key to a plus key kept the toggle enabled.

Now always verifies via API when a license key is present. Cached
validation (grace period, permanent count) is only used as an
offline fallback when the API call fails. Also auto-revokes
enableSelfHostMode when the API confirms the key is not eligible.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:34:45 -08:00
Logan Yang
69f668f7f3
Replace warning triangle with Build Index button in Relevant Notes (#2168)
* Replace warning triangle with Build Index button in Relevant Notes

Improves UX when semantic search is off or index is unavailable by replacing
the yellow warning triangle and text with a clear "Build Index" button that
enables semantic search (with confirmation modal) and triggers vault indexing.

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

* Add reactive index signal and move Build Index button to header

- Create indexSignal pub-sub so UI reacts to external index changes
  (clear, rebuild, reindex) without manual refresh
- Fire notifyIndexChanged() from VectorStoreManager (single source)
- Move Build Index button to header area so it's always visible when
  !hasIndex, even when link-based relevant notes are showing
- Update help tooltip to mention semantic search requirement

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:39:00 -08:00
Logan Yang
35bc50b47c
Remove HyDE query rewriting from HybridRetriever (#2167)
HyDE (Hypothetical Document Embeddings) rewrites user queries into
hypothetical answer passages before embedding them for vector search.
This produces garbage for non-question queries like "find all my ai
digests" — the LLM generates an instructional essay instead of useful
search terms, polluting semantic results and causing Plus mode to miss
relevant notes that Agent mode finds via multiple search iterations.

Removing HyDE:
- Eliminates an extra LLM call per search (faster responses)
- Uses the raw query for vector embedding search (better matching)
- QueryExpander already handles LLM-based query expansion correctly
- hybridRetriever.ts is already marked DEPRECATED

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:56:51 -08:00
Logan Yang
baea3079c8
Update builtin models to latest versions across all providers (#2166)
- Add Claude Opus 4.6, Sonnet 4.5 (replacing Sonnet 4)
- Add Gemini 3 Flash/Pro preview (Google direct + OpenRouter)
- Add GPT-5.3 Codex, upgrade OpenRouter GPT to 5.2/5-mini
- Upgrade Grok 4-fast to 4.1-fast (xAI + OpenRouter)
- Remove Gemini 2.5 Flash Lite, demote GPT-4.1 to non-core
- Preview models are not core or project-enabled
- Extend thinking model detection for Claude Opus 4.x

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:34:58 -08:00
Logan Yang
cbe75e76a3
Fix search recall with note-diverse top-K and chunk-aware scoring (#2165)
* fix: improve search recall with note-diverse top-K, chunk-aware scoring, and adaptive cutoff module

- Add note-diverse top-K selection so every unique note gets representation
  before any note gets a second chunk slot (fixes "find my ai digests" missing notes)
- Fix GraphBoostCalculator: resolve chunk IDs to note paths for metadata lookups,
  deduplicate to note level in filterCandidates
- Fix FolderBoostCalculator: count unique notes per folder instead of raw chunks
- Bump title field weight 3→5 for stronger filename match priority
- Add AdaptiveCutoff module (standalone, not yet plugged in) for score-based
  adaptive result limiting — designed for future Jina Reranker v3 integration
- Extract shared chunkIdUtils for consistent chunk ID → note path conversion

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

* Hardcode maxSourceChunks to 30 and remove user setting

Replace settings.maxSourceChunks with DEFAULT_MAX_SOURCE_CHUNKS (30)
across all retriever call sites. Remove the "Max Sources" slider from
QA settings — recall and precision are now handled automatically by
diverse top-K selection.

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

* feat: expose returnAll to LLM for exhaustive "find all" vault queries

Queries like "find all my AI digests" previously capped at 30 results.
Time-range and tag queries already bypassed this via shouldReturnAll,
but the trigger was mechanical. This exposes the returnAll concept to
both Agent chain (via tool schema + instructions) and CopilotPlus chain
(via planning prompt extraction) so the LLM can set it based on user
intent, returning up to 100 results when appropriate.

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

* fix: replace raw tool names with human-friendly summaries in reasoning block

Add explicit cases for all tools (getFileTree, getTagList, time tools,
updateMemory, indexVault, youtubeTranscription) so the agent reasoning
block never exposes internal tool names. Default fallbacks now show
"Processing" / "Done" instead of "Calling toolName" / "Completed toolName".
Failure messages also use the friendly summary.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:31:59 -08:00
Wenzheng Jiang
bee0a66958
Miyo Integration Phase 1: abstract semantic index backend (#2155)
* refactor: abstract semantic index backend

* refactor: abstract get documents by path
2026-02-07 21:39:59 -08:00
Logan Yang
8cea1a6f17
Audit context envelope, tag alignment, artifact dedup, and logging (#2164)
* fix: align context block tags and make parseContextIntoSegments registry-driven

The context envelope L2 layer was silently dropping URL/media context across
turns due to three compounding issues:

1. Tag mismatch: Mention.ts created <youtube_transcript> blocks but the
   contextBlockRegistry expected <youtube_video_context>. Fixed the tag and
   inner element (<transcript> → <content>) to match the registry.

2. Missing registration: twitter_content blocks had no registry entry, so
   they were invisible to compaction and segment parsing. Added the entry.

3. Hardcoded parser: parseContextIntoSegments only matched <note_context>,
   <active_note>, and <prior_context> via hardcoded regexes, silently
   ignoring all URL/media block types. Rewrote it to dynamically build its
   regex from CONTEXT_BLOCK_TYPES, ensuring any future block type added to
   the registry is automatically handled.

Extracted parseContextIntoSegments into src/context/parseContextSegments.ts
as a standalone pure function for direct testability.

Added 19 new tests covering all registered block types, mixed blocks,
stable/unstable flags, and a registry completeness guard test.

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

* fix: context envelope improvements - regeneration, dedup, per-artifact IDs, XML escaping

Phase A: Fix regeneration from loaded chats by lazily reprocessing context
when contextEnvelope is missing (e.g., messages loaded from disk).

Phase B: Replace static segment IDs ("urls", "selected_text", "web_tabs")
with per-artifact IDs from parseContextIntoSegments, enabling accurate
smart referencing in LayerToMessagesConverter.

Phase C: Deduplicate L2 content by segment ID (last-write-wins) to prevent
linear growth when the same artifact appears across multiple turns.

Phase D: Escape note title/basename in XML context blocks for consistency
with URL/YouTube content. Keep path unescaped as it's used as identifier.

Additional fixes from Codex review:
- Unique IDs for blocks without source extractors (selected_text counter)
- Keep note.path raw to avoid dedup mismatch with TFile.path

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

* fix: prevent context artifact duplication in LLM payload and L4 memory

CopilotPlusChainRunner was re-injecting processedText (which includes
context XML artifacts) into the user message via ensureUserQueryLabel,
bypassing the envelope's clean L2/L3/L5 separation. This caused context
like YouTube transcripts to appear 3x in the LLM payload.

Fix: use L5_USER envelope text (expanded user query without context XML)
instead of processedText for cleanedUserMessage, trimmedQuestion fallback,
and messageForAnalysis. Also fix BaseChainRunner.handleResponse to save
L5 text to L4 memory instead of processedText.

Other changes:
- Remove dead updateMemoryWithLoadedMessages from chainManager
- Update CONTEXT_ENGINEERING.md with example walkthrough, L4 behavior
  docs, and Phase 6 integration test suite roadmap

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

* docs: add chain runner envelope usage section and fix Step 0 log

Document per-runner envelope behavior, CopilotPlus/Agent tool flows,
and token efficiency audit in CONTEXT_ENGINEERING.md. Fix Step 0 log
to show L5 user query instead of processedText with context XML.

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

* fix: trim trailing whitespace from chat input display text

Trailing newlines from Lexical editor were preserved in displayText
and rendered as empty lines in chat bubbles due to whitespace-pre-wrap.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 21:18:25 -08:00
Logan Yang
54ca66f5a3
Add twitter4llm support for Twitter/X URL processing (#2161)
Route Twitter/X URLs (x.com, twitter.com with /status/) to the new
brevilabs twitter4llm endpoint instead of the generic url4llm endpoint.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:54:18 -08:00
Logan Yang
b8efcc1c81
fix: remove tiktoken remote fetch from critical LLM path (#2160)
LangChain's default getNumTokens() fetches a ~3MB gpt2.json BPE
vocabulary from tiktoken.pages.dev at runtime. When this CDN is
unreachable, all LLM calls (stream, invoke) hang until timeout,
completely blocking the plugin. Override getNumTokens on every model
instance with a simple char-based estimation — modern LLM APIs return
accurate token usage in response metadata anyway.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:17:55 -08:00
Logan Yang
c93358df82
Implement modular context compaction architecture (#2159)
* refactor: modular context compaction architecture

Split context compaction into focused modules:
- contextBlockRegistry: single source of truth for block types
- compactionUtils: shared pure functions for truncation/extraction
- ChatHistoryCompactor: tool result compaction at memory save time
- L2ContextCompactor: L3→L2 context compaction with previews

Key changes:
- Never compact selected_text (non-recoverable content)
- Single consolidated re-fetch instruction at end of L2 context
- Write-time compaction via memoryManager.saveContext()
- Parse prior_context blocks in segment extraction
- Backwards compatibility via re-exports

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

* fix: use brace-balanced extraction for readNote JSON parsing

The previous regex-based approach stopped at the first closing brace,
truncating JSON payloads containing nested braces (e.g., code snippets).
This caused JSON.parse to fail and skip compaction entirely.

Now uses a proper brace-balanced parser that correctly handles:
- Nested objects and arrays
- Braces inside string literals
- Escaped characters in strings

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

* fix: preserve all documents in localSearch compaction

Previously, compactToolResultBlock used extractContentFromBlock which
only extracts the first <content> match. For localSearch results with
multiple <document> blocks, this caused all documents after the first
to be lost during compaction.

Now localSearch blocks are handled specially:
- All documents are extracted and preserved
- Each document keeps its title and path
- Content is truncated to preview length
- Output lists all documents with [[wikilink]] format

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

* test: skip flaky composer integration tests

Skip composer integration tests due to intermittent 503 errors from
Gemini API causing CI failures. The tests depend on external API
availability which is unreliable.

TODO(@logancyang, @wenzhengjiang): Re-enable once composer is revamped.

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

* fix: preserve all blocks when compacting multi-block L2 segments

When URL context segments contain multiple concatenated <url_content>
blocks (from processUrlList), the previous compaction only processed
the first block. Now detects multiple blocks of the same type and
compacts each one independently, preserving all URL context.

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

* fix: preserve mixed XML block types during L2 compaction

The previous fix only handled multiple blocks of the same type. When
segments contain mixed block types (e.g., web_tab_context mixed with
youtube_video_context from processContextWebTabs), blocks of other
types were dropped.

Now uses CONTEXT_BLOCK_TYPES registry to match all known block types
and compacts each one independently, preserving all context regardless
of block type mix.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 21:52:33 -08:00
Emt-lin
c76235a326
fix: improve mobile keyboard/navbar CSS scoping and platform detection (#2157)
* fix: improve mobile keyboard/navbar CSS scoping and platform detection

* fix: clear drawer keyboard class when view moves out of drawer

Track the last drawer element so that when the Copilot view is moved
out of a drawer while the soft keyboard is open, the copilot-keyboard-open
class is properly removed from the old drawer, restoring its header/tab UI.
2026-02-06 10:09:47 -08:00
Logan Yang
aa71040702
Fix lm studio chat with only ending think tag (#2153)
* Fix missing opening <think> tag for models like nvidia/nemotron

Some models output thinking content with only a closing </think> tag
due to misconfigured chat templates in LM Studio. This adds post-processing
to detect and fix this by prepending the opening <think> tag, with a
warning logged to help users identify the template issue.

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

* Fix agent mode to properly strip thinking tokens from response

Agent mode was showing thinking content in final responses because
streamModelResponse() bypassed ThinkBlockStreamer entirely. Now
integrates ThinkBlockStreamer with excludeThinking=true to properly
filter thinking tokens from all sources (Claude, Deepseek, OpenRouter,
LM Studio text-level <think> tags).

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:05:31 -08:00
Logan Yang
27d7a667b2
Add reasoning/thinking token support for LM Studio (#2151)
* Add reasoning/thinking token support for LM Studio

- Use ChatOpenRouter for LM Studio to capture delta.reasoning from streaming responses
- Add enableReasoning and reasoningEffort config for LM Studio models
- Show Reasoning Effort UI for any model with REASONING capability enabled
- Consolidate duplicate OpenRouter reasoning effort UI into unified component
- Translate Chinese comments to English

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

* Update GPT-5 to GPT-5.2 and add xhigh reasoning effort

- Rename GPT_5 to GPT_5_2 (model name "gpt-5.2")
- Add XHIGH reasoning effort level for GPT-5.2 models
- Update ChatOpenRouter type to support "xhigh" effort
- Show Extra High option only for GPT-5.2 OpenAI models
- Translate Chinese comments to English

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

* Fix tool_calls not forwarded in ChatOpenRouter message conversion

The toOpenRouterMessages() method was only handling legacy function_call
but not modern tool_calls format, causing autonomous agent tool flows
to break for LM Studio and OpenRouter models.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 18:29:09 -08:00
Emt-lin
57e95c34e6
fix: GitHub Copilot mobile CORS bypass and auth UX improvements. (#2140)
# Conflicts:
#	src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts
2026-02-04 17:32:56 -08:00
Emt-lin
918969fc18
feat: add PatternListEditor component for include/exclude settings (#2141)
* feat: add PatternListEditor component for include/exclude settings

Add a new PatternListEditor component to manage include/exclude patterns
in QA settings with support for folders, tags, notes, and file extensions.

- Collapsible list UI with smooth expand/collapse animation
- Dropdown menu for adding different pattern types via modals
- Robust URI decoding with try/catch to prevent settings page crash
- Pattern deduplication via getUniquePatterns helper
- Test coverage for malformed URI sequences

# Conflicts:
#	src/styles/tailwind.css

# Conflicts:
#	src/styles/tailwind.css

* chore: translate Chinese comments to English in PatternListEditor
2026-02-04 17:31:55 -08:00
Logan Yang
b128b25558
Agent UIUX Improvements (#2149)
* Use openrouter openai embedding small as default embedding model

* Increase agent max iterations to 16 and add 5-minute loop timeout

- Raise max tool call iterations limit from 8 to 16
- Add AGENT_LOOP_TIMEOUT_MS constant (5 minutes) to prevent runaway loops
- Update slider max in settings UI to match new limit
- Show appropriate message when loop exits due to timeout vs max iterations

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

* Update agent reasoning spinner to sigma shape with smooth animation

- Change from 9-dot diamond to 7-dot sigma (Σ) pattern
- Animation traces sigma shape: top-right → top-left → center → bottom
- Use ease-in-out timing and gradual opacity transitions for smooth motion
- Adjust size to 13.5px for better alignment with text

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

* Improve agent reasoning step summaries and fix composer tool UX

- Add meaningful summaries for writeToFile/replaceInFile tools
  (e.g., "Writing to filename" instead of "Calling writeToFile")
- Show "Edit rejected" when user rejects changes in diff view
- Fix "Changes applied successfully" notice showing on reject
- Extract model's intermediate reasoning as finding summaries
- Add TODO doc for composer tool redesign to address 30s feedback gap

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

* Add self-host mode eligibility gating and permanent validation

- Increase tool timeout from 60s to 120s
- Show self-host mode section only to Believer/Supporter plan users
- Add permanent validation after 3 consecutive successful checks
- Add useIsSelfHostEligible hook for offline-friendly eligibility check
- Preserve permanent validation status when re-enabling self-host mode

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

* Fix self-host validation to require 14-day intervals between count increments

- Only increment validation count when 14+ days have passed since last check
- Document need for structured ComposerTools results in TODO doc
- Add TODO for no-op case handling in AgentReasoningState

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

* Fix self-host validation timer and offline re-enable issues

- Don't reset timestamp when < 14 days (preserves interval countdown)
- Allow permanently validated users to see section even if toggle is off
- Supports offline re-enable for users with 3+ successful validations

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

* Wire refreshSelfHostModeValidation into plugin startup

Enables the 14-day periodic validation to actually increment the count
toward permanent self-host mode (3 successful checks).

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

* Improve self-host mode validation with offline support and documentation

- Add grace period check in validateSelfHostMode for offline re-enable
- Change grace period from 14 to 15 days
- Add comprehensive documentation explaining validation flow
- Fix offline re-enable for users within grace period

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:10:28 -08:00
Logan Yang
54e341ca88
Update ollama (#2147)
* Update ollama chat model to fix thinking

* fix: consolidate tool call parsing and add actionable error logging

- Remove duplicate tool call parsing in AutonomousAgentChainRunner
  by using shared buildToolCallsFromChunks() function
- Add sanitizeToolArgs() to remove empty objects from tool args
  (fixes Ollama returning timeRange: {} which fails Zod validation)
- Add actionable logging for tool call failures with full args context
- Clean up unused parameters in AutonomousAgentChainRunner
- Simplify ToolManager.callTool to throw instead of swallowing errors

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

* fix: add repeatPenalty for Ollama to reduce repetitive outputs

Local models via Ollama are prone to repetitive hallucination loops.
Adding repeatPenalty=1.1 applies a slight penalty to repeated tokens,
which helps reduce this pattern.

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

* fix: increase Ollama context window to 128k

Default numCtx of 8192 causes prompt truncation with larger contexts.
Setting numCtx=131072 (128k) to support models with large context.
Ollama automatically caps at model's actual maximum if exceeded.

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

* fix: handle Ollama empty object tool args at schema level

- Make timeRange inner fields (startTime, endTime) optional in schema
- Update validateTimeRange to handle empty or partial objects from LLMs
- Apply validation in all search tools (local, lexical, semantic)
- Remove sanitizeToolArgs function that was too aggressive
- Fixes tool calls that legitimately accept {} as valid input

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:18:39 -08:00
Logan Yang
308c88332d
Address quick ask (#2146)
* chore: translate Chinese comments to English and apply formatting

- Translate all Chinese comments in replaceGuard.ts to English for consistency
- Apply Prettier formatting to 33 files

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

* test: add unit tests for Quick Ask core modules

Add comprehensive tests for:
- replaceGuard.ts: validation logic, error messages, MapPos and Highlight strategies
- persistentHighlight.ts: CM6 state field, range mapping, show/hide behavior
- quickCommandPrompts.ts: placeholder appending logic

64 new test cases covering edge cases like:
- Leaf/editor/file change detection
- Document bounds validation
- Content change detection
- Range mapping through document changes
- Case-insensitive placeholder detection

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:04:10 -08:00
Emt-lin
76520d0259
Add Editor "Quick Ask" Floating Panel + Persistent Selection Highlights (#2139)
* feat: implement floating quick command with AI-powered inline editing

Add a new floating quick command feature that allows users to interact
with AI directly from the editor. Key features include:

- Quick Ask panel with draggable modal interface
- Multiple interaction modes: ask, edit, and edit-direct
- Selection highlight and replace guard for safe text operations
- Streaming chat session support for real-time AI responses
- Context menu integration for quick access
- Resizable and draggable UI components

# Conflicts:
#	src/commands/CustomCommandChatModal.tsx
#	src/styles/tailwind.css

* feat: add persistent selection highlight for Chat panel

* refactor: extract persistent highlight factory and fix state consistency

* fix: improve popup positioning with flip logic and line-start trap fix

- Use selection.head instead of selection.to for anchor point
- Fix line-start trap: only apply when head is selection end
- Add flip logic: show above when not enough space below
- Add horizontal visibility check
- Single-line selection: center popup horizontally
- Multi-line selection: follow head position
- Cursor mode: align near cursor instead of centering
2026-02-03 17:37:33 -08:00
Zero Liu
800547534a
fix: prevent arrow keys from getting stuck in typeahead with no matches (#2137)
When the typeahead menu is triggered but the query matches nothing,
the menu visually disappears but state.isOpen remains true. Arrow key
handlers were checking only isOpen, intercepting events even with no
options to navigate. This trapped the cursor.

Add early return (return false) in ArrowDown/ArrowUp handlers when
options.length === 0, consistent with how Enter/Tab already handle
this case. Arrow keys now propagate normally for cursor movement
when there are no typeahead matches.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:06:04 -08:00
Logan Yang
1e3e0786ef
Migrate to native tool call in plus and agent (#2123)
* Implement self-host mode
* Migrate from XML tool calls to native tool calls and reimplement agent mode
* Use ChatOpenRouter for copilot-plus-flash for native tool call sse support
* Fix QA exclusion for search v3
* Update plan and remove debug messages in agent
* Migrate chains off of xml and clean up related logic
* Implement agent reasoning block
* Update css for responsiveness
* Implement agent query pre-expansion and proper reasoning block display for search
* Update agent docs
* Refine agent reason block
* Refine agent reasoning UX
* Fallback to plus non-agent if native tool call is not supported by the model
* Fix time filter query
* Update self-host description
* Skip agent reasoning block and old tool call banners during chat save and load
* Fix projects mode switch to plus and chat auto saves as non-project bug
2026-01-26 18:35:27 -08:00
Logan Yang
fdef63fe0e
fix: increase grep limit for larger vaults and unify chunking (#2117)
* fix: increase grep limit for larger vaults and enhance search prioritization
* fix: unify chunk management over index-free and semantic search
* fix: batch process files in prepareAllChunks to bypass ChunkManager's 1000-file limit
* fix: update regex to correctly find closing frontmatter delimiter at EOF
* fix: enhance security checks to prevent path traversal vulnerabilities
* fix: refactor to use shared ChunkManager instance across components for improved cache management
* fix: enhance cache retrieval in ChunkManager to support non-default chunk options
2026-01-19 11:43:38 -08:00
Logan Yang
382d88328e
3.1.5 (#2116) 2026-01-15 12:36:07 -08:00
Logan Yang
1d937cf50e
Adjust settings (#2115)
* feat: update setting slider value display and modify auto-compact threshold description

* feat: update auto-compact threshold description for clarity

* feat: improve display of large slider values with better formatting
2026-01-15 11:53:49 -08:00
Emt-lin
5bfb6fb6f5
fix: Github Copilot add streaming support and improve robustness (#2113)
- Add streaming response with SSE parsing (eventsource-parser)
- Add Accept: text/event-stream header and content-type validation
- Add empty output detection to prevent silent failures
- Unify token expiry logic between getAuthState() and getValidCopilotToken()
- Add proper resource cleanup with reader.cancel()
- Add request cancellation handling in GitHubCopilotAuth component
- Extract HTTP_STATUS_MESSAGES constant
- Remove fallback mechanism for simpler error handling
2026-01-15 11:31:48 -08:00
Emt-lin
957b62a9f3
Feat/default indicator and slash command fix (#2114)
* fix: record usage when selecting slash command from input

Slash commands selected via "/" in the chat input were not updating
lastUsedMs, causing recency sorting to not work properly. Other entry
points (command palette, context menu) already called recordUsage.

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

* feat: add default indicator for system prompts

- Rename "(Global)" to "(Default)" in system prompt dropdown labels
- Add "(Default)" marker in AdvancedSettings for selected default prompt
- Sync default flag to frontmatter when default system prompt changes
- Handle edge case when folder and default title change simultaneously
- Add logWarn when prompt file not found during default flag update

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:31:31 -08:00
Logan Yang
b740614d2d
Support openrouter embeddings (#2112)
* Remove auto-accept edit from settings

* feat: add OpenRouterAI support in embedding manager and model dialog
2026-01-14 17:00:09 -08:00
Logan Yang
ab0895a382
Remove transcript (#2111)
* feat: remove transcript formatting instructions from constants

* feat: remove Chinese summary and key takeaways sections from constants
2026-01-14 15:14:48 -08:00
Wenzheng Jiang
2b139862e3
feat: simplify diff views to side-by-side and split modes with word-level highlighting (#2108)
* feat: add side-by-side diff view option for ApplyView

Add a toggle to switch between inline and side-by-side diff views in the
ApplyView component. The side-by-side view shows original content on the
left and modified content on the right, with word-level diff highlighting.

- Add diffViewMode setting to persist user preference
- Add Tabs UI in header to switch between views
- Create SideBySideBlock component for two-column diff display
- Support word-level highlighting for paired add/remove lines

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

* fix: preserve change order in side-by-side diff view

Fix issue where splitting block into removedLines and addedLines and
pairing by index would incorrectly pair unrelated lines. Now iterates
through the block in order and only pairs removed+added when they are
adjacent (indicating an actual replacement).

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

* feat: simplify diff views to side-by-side and split modes with word-level highlighting

- Remove inline diff mode, keeping only side-by-side and split modes
- Add word-level highlighting to both modes for clearer change visualization
- Split mode shows original and modified content separately with highlighted words
- Side-by-side mode pairs adjacent changes with word-level diff
- Default to split mode for cleaner diff view on large changes
- Add migration for users who had inline mode saved in settings

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

* fix: add JSDoc to wordLevelDiff and fallback for diffViewMode setting

- Add JSDoc documentation to the wordLevelDiff function to comply with
  project coding standards
- Add nullish coalescing fallback for diffViewMode setting so users
  with pre-existing settings get a deterministic default value

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

* refactor: extract shared diff utilities and reduce code duplication

- Extract buildDiffRows function to eliminate duplicate row-building logic
- Create WordDiffSpan and DiffCell components for unified diff rendering
- Simplify tokenizer using regex-based split for better performance
- Add useMemo to prevent unnecessary recalculations
- Reduce file from ~813 to ~707 lines (~13% reduction)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:03:48 -08:00
Emt-lin
7f8b94311f
feat: add GitHub Copilot integration with improved robustness (#2110)
* feat: add GitHub Copilot integration with improved robustness

- Add GitHub Copilot as new LLM provider with OAuth device flow
- Implement GitHubCopilotProvider for token management and API calls
- Implement GitHubCopilotChatModel as LangChain BaseChatModel adapter
- Add GitHubCopilotAuth UI component for OAuth authentication
- Extract reusable ModelImporter component from ApiKeyDialog

Improvements:
- Use authGeneration counter to prevent race conditions on reset
- Add token validation and expires_at type handling (s/ms/string)
- Add MAX_REFRESH_ATTEMPTS to prevent infinite refresh loops
- Add try/catch/finally for robust error handling in ModelImporter
- Clean up AbortController lifecycle in polling operations

* fix: reset model list when provider credentials change

- Add credentialVersion prop to ModelImporter for detecting key changes
- Clear cached models when credentialVersion changes (not just isReady)
- Pass API key/token as credentialVersion in ApiKeyDialog and GitHubCopilotAuth
- This ensures model list refreshes when rotating keys (non-empty to non-empty)

* fix: treat Copilot access token as valid credential

Check both githubCopilotToken and githubCopilotAccessToken when
validating credentials. This allows the model to work when OAuth
succeeds but fetchCopilotToken fails, since getValidCopilotToken()
can still refresh using the access token.
2026-01-14 15:01:47 -08:00
Logan Yang
4a0f742eff
Fix deps (#2109) 2026-01-13 23:04:27 -08:00
Logan Yang
a576ad7986
feat: implement auto-compact context with map-reduce summarization (#2106)
- Add ContextCompactor class for map-reduce style context summarization
- Implement auto-compact when context exceeds configurable threshold
- Track note paths for tags/folders in L3 segment metadata for L2 deduplication
- Add loadAndAddChatHistory for streamlined chat history loading
- Store compacted paths in envelope metadata for multi-turn deduplication
- Fix: Only track L3 context paths in compactedPaths (excludes L5 user message files)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:15:24 -08:00
Emt-lin
bb2ebf1c9b
feat: Add comprehensive system prompt management system. (#1969)
* feat: Add comprehensive system prompt management system.

* fix:fixed some bugs

* fix: prevent false negatives in migration content verification

Strip leading newlines before comparing saved vs original content.
Obsidian may insert extra blank line after frontmatter block,
causing stripFrontmatter to leave a leading newline and fail verification.
2026-01-13 21:38:17 -08:00
Emt-lin
538033e478
File status and think block state (#2087)
* feat: add file processing status display in context manage modal.

* fix: preserve think block collapse state across streaming -> history transition.

# Conflicts:
#	src/components/chat-components/ChatSingleMessage.tsx

* fix: improve tool call root and think block state handling during streaming transitions

- Add container reference to ToolCallRootRecord for detecting container changes
- Handle container mismatch when streaming component unmounts and history mounts
- Use container.isConnected for stale cleanup instead of timestamp-only approach
- Prevent double toggle on think blocks by handling state in pointerdown

* fix: prevent current project file status from falling through to cached check

For the current project, if a file is not in processing/failed/success
real-time state, it should show "Not started" instead of incorrectly
showing "Processed" based on cached fileContexts entries.
2026-01-12 22:52:35 -08:00
Emt-lin
01f5bedbc2
fix: always process think blocks regardless of current model selection. (#2099) 2026-01-12 19:37:27 -08:00
Wenzheng Jiang
8e2f9c413b
Add auto-accept edits toggle in chat control setting. (#2076)
Co-authored-by: Logan Yang <logancyang@gmail.com>
2026-01-12 19:35:15 -08:00
Emt-lin
666f281419
Refactor model API key handling and improve model filtering. (#2003)
* Refactor model API key handling and improve model filtering.

# Conflicts:
#	src/settings/v2/components/ModelAddDialog.tsx

# Conflicts:
#	src/components/ui/ModelSelector.tsx
#	src/settings/v2/components/ApiKeyDialog.tsx

# Conflicts:
#	src/settings/v2/components/ApiKeyDialog.tsx
#	src/settings/v2/components/ModelAddDialog.tsx
#	src/settings/v2/components/ModelEditDialog.tsx

* Fix local models being filtered out due to API key checks

* Fix circular dependency in modelUtils causing test failures

Changed REQUIRED_MODELS from a top-level constant to a getter function to avoid circular dependency issues during module initialization. This fixes test failures where ChatModels enum was accessed before being fully initialized.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-12 19:34:46 -08:00
Emt-lin
cd6e4fc5ae
feat: add recent usage sorting for chat history and project list. (#2077)
* feat: add recent usage sorting for chat history and project list.

# Conflicts:
#	src/main.ts

* fix: use in-memory recency for chat history modal sorting.

* refactor: move sort strategy selectors to AdvancedSettings.
2026-01-12 19:29:41 -08:00
Pleasure1234
270663aa1b
fix: update ApiKeyDialog layout for better visibility (#2081)
Removed max height restriction from ApiKeyDialog.
2026-01-12 18:09:58 -08:00
Emt-lin
a698549b4b
feat: Enhance Model Settings with Local Services and Curl Command Support. (#2098)
* feat: Enhance Model Settings with Local Services and Curl Command Support.

* fix: add OpenRouter-specific headers to generated curl commands.
2026-01-12 18:07:51 -08:00
Emt-lin
98caf34180
fix: Fix view-content padding for different display modes. (#2100) 2026-01-12 18:04:33 -08:00
Emt-lin
ff3636637f
feat: Add Web Viewer bridge for referencing open web tabs in chat (#2096)
* feat: Add Web Viewer bridge for referencing open web tabs in chat.

* refactor: Simplify markdown image extraction parser。

* fix: Prevent web selection from auto-reappearing after removal or new chat.

* fix: Suppress active web tab when web selection exists.

* feat: Add YouTube transcript extraction for Web Viewer tabs.

* feat: Built-in slash prompts for web clippers.

* feat: Improve web selection tracking and simplify context settings

  - Merge context settings: combine note/web active content and selection settings
  - Add auto-clear for web selection badge when deselected on page
  - Make note and web selections mutually exclusive
  - Show website favicon in web selection badge
  - Add migration logic and tests for settings
2026-01-12 17:54:54 -08:00
Logan Yang
ccbe039a86
Fix search v3 ranking (#2101)
* Replace FlexSearch with MiniSearch for improved search functionality and scoring

* Enhance QueryExpander and README for improved recall and ranking clarity

- Update QueryExpander to clearly separate salient and expanded terms for recall and ranking.
- Modify README to reflect changes in the search system's architecture and clarify the distinction between recall and ranking processes.
- Adjust FullTextEngine comments to emphasize BM25's native multi-term scoring capabilities.

* Implement weighted query expansion in FullTextEngine with 90/10 scoring for salient and expanded terms
2026-01-09 22:30:30 -08:00
Logan Yang
13523c9fc1
3.1.4 (#2074) 2025-12-15 22:00:08 -08:00
Logan Yang
ddbfc5621d
Bring back toggle for inline citation (#2073) 2025-12-15 21:48:09 -08:00
Logan Yang
214627c64c
Fix background tool handling to prevent orphaned spinners in agent conversation (#2072) 2025-12-15 21:28:20 -08:00
Logan Yang
60b380117d
Clean up dead code and update readme for privacy disclosure (#2071) 2025-12-15 20:50:46 -08:00
Logan Yang
959ddf4fae
Enhance error handling in BaseChainRunner with detailed user guidance and authentication checks (#2070) 2025-12-15 20:04:39 -08:00
Wenzheng Jiang
2d453d318d
Add path to variable_note format and reorder elements (#2049)
Added <path> tag to variable_note XML format for {activeNote} and other variable types. Reordered format so path comes before title heading to match note_context structure. Updated all test expectations to reflect the new format.

This change ensures the LLM receives file path information when processing variable notes, enabling better context understanding and file reference capabilities. The consistent format with note_context also improves maintainability.
2025-12-15 19:16:49 -08:00
Zero Liu
3f6f241142
Improve relevant note search algorithm (#2052) 2025-12-15 19:16:00 -08:00
Logan Yang
1d668d3367
Deprecate IntentAnalyzer (#2069)
* Deprecate IntentAnalyzer and replace with model-based tool planning in CopilotPlusChainRunner
* Refine planning prompt to enforce XML-only responses and remove note extraction logic
2025-12-15 19:15:44 -08:00
Logan Yang
71bf510acb
Improve new user onboarding by removing notice on missing api key (#2063) 2025-12-11 13:44:21 -08:00
Logan Yang
e05e3fafef
Revert "Improve onboarding by removing the popups for no api key and moving it to chat response (#2015)" (#2038)
This reverts commit 8b32f6f919.
2025-11-21 08:10:08 -08:00
Logan Yang
88feebf486
3.1.3 (#2025) 2025-11-11 17:07:35 -08:00
Logan Yang
6b48e1cac7
Do not show thinking if reasoning is not checked (#2024)
* Do not show thinking if reasoning is not checked

* Fix test
2025-11-11 16:35:21 -08:00
Logan Yang
fec98a7107
Enable agent by default (#2023)
* Enable agent by default

* Update gemini model adapter to fight hallucination
2025-11-11 13:49:26 -08:00
Logan Yang
ff278c4d3e
Add auto selection to context setting (#2018)
* Add setting for auto text selection inclusion and add back manual command

* Group chat save settings

* Update constants
2025-11-09 19:56:07 -08:00
Logan Yang
72f34463c2
Implement auto context inclusion on text selection (#2017)
* Implement auto context inclusion on text selection

* Add text selection badge to posted user message
2025-11-09 15:41:53 -08:00
Logan Yang
bfa14bf1a8
Fix thinking model verification (#2016) 2025-11-09 13:17:08 -08:00
Logan Yang
8b32f6f919
Improve onboarding by removing the popups for no api key and moving it to chat response (#2015) 2025-11-09 00:35:40 -08:00
Logan Yang
0eab7c9656
Update log file (#2014) 2025-11-08 23:02:55 -08:00
Logan Yang
155bd42a78
Fix bedrock model image support (#2012) 2025-11-08 18:49:35 -08:00
Logan Yang
b1a1c539b3
Update bedrock model support (#2011) 2025-11-08 17:22:36 -08:00
Zero Liu
820947cac8
Multiple UX improvement (#2010)
* Close typeahead menu if start with space

* fix dnd with dot filename

* Add model select max height
2025-11-07 12:03:39 -08:00
Wenzheng Jiang
c6608f3a40
Enhance writeToFile tool with confirmation option (#2002)
- Added an optional `confirmation` parameter to the `writeToFile` tool, allowing users to skip the preview UI and apply changes directly to the file.
- Updated the tool's schema to include the new parameter, with a default value of true for confirmation.
- Enhanced the handler logic to manage file writing based on the user's confirmation choice, improving flexibility in file operations.
2025-11-07 12:02:27 -08:00
Logan Yang
5303a6aa9f
Add anthropic version required field for bedrock (#2008) 2025-11-05 14:47:05 -08:00
Viktor Vedmich
c44131b926
feat: Add AWS Bedrock cross-region inference profile guidance (#2007)
Improves AWS Bedrock support by adding user guidance for cross-region
inference profiles, addressing issue #2006.

Changes:
- ModelAddDialog: Add helper text and example for Bedrock inference profiles
- ModelEditDialog: Update region field description with shorter text
- CLAUDE.md: Add AWS Bedrock usage documentation with examples

Users are now guided to use cross-region inference profile IDs
(global., us., eu., apac. prefixes) instead of regional model IDs,
which significantly improves reliability and availability.

Fixes: logancyang/obsidian-copilot#2006

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 14:41:24 -08:00
Zero Liu
8c1a77178e
Fix template note processing (#2001) 2025-11-04 12:23:08 -08:00
Logan Yang
ba4eba5dcf
3.1.2 (#1998) 2025-10-31 18:35:23 -07:00
Logan Yang
1cd87d75f3
Fix guidance (#1997)
* refactor: Simplify local search guidance handling in AutonomousAgentChainRunner and CopilotPlusChainRunner

- Removed the `appendInlineCitationReminder` function and adjusted the logic in `AutonomousAgentChainRunner` to directly manage local search guidance.
- Updated `CopilotPlusChainRunner` to retain and utilize the most recent local search guidance, ensuring it is positioned correctly adjacent to user queries.
- Modified `buildLocalSearchInnerContent` to accept introductory text instead of guidance, enhancing clarity in the payload structure.
- Removed related tests for the deprecated `appendInlineCitationReminder` function, streamlining the codebase.

* refactor: Enhance localSearch payload structure and guidance handling

- Made localSearch payload self-contained by embedding RAG instructions, documents, and citation guidance directly within the `<localSearch>` block.
- Updated AutonomousAgentChainRunner and CopilotPlusChainRunner to ensure each localSearch call carries its own guidance block, improving citation accuracy in multi-search scenarios.
- Introduced a new utility function to insert guidance before the user query label, ensuring clarity in the payload structure.
- Adjusted related tests to validate the new guidance handling logic.

* docs: Add deprecation guide for IntentAnalyzer and Broca

- Created a comprehensive document outlining the responsibilities of the `IntentAnalyzer` and Broca API within the Copilot Plus intent analysis flow.
- Detailed the current functionalities, known consumers, migration constraints, and a phased migration plan to ensure feature parity and smooth transition.
- Highlighted risks and mitigation strategies associated with the deprecation process, along with open questions for further consideration.

* fix: Prevent infinite rolling animation in ToolCallBanner when result is present

Add defensive check that stops animation when tool result exists, even if isExecuting flag wasn't updated. This fixes race conditions where marker updates fail or are delayed during streaming.

🤖 Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
2025-10-31 18:25:41 -07:00
Logan Yang
16c42fcf2f
feat: Add function to escape tasks code blocks in ChatSingleMessage component (#1996)
- Introduced `escapeTasksCodeBlocks` to convert ```tasks to ```text, preventing execution in AI responses and ensuring static display of task queries.
- Updated processing logic to utilize the new function, enhancing content safety and display consistency.
2025-10-31 14:02:21 -07:00
Logan Yang
afb41c9179
Support embedded note (#1995)
* Update bug report template to enhance clarity and requirements

- Changed the required checklist item from a screenshot to a log file generated via the "Copilot: Create Log File" command.
- Made the screenshot of the note and Copilot chat pane optional.
- Clarified the statement regarding bug report requirements for better understanding.

* feat: Implement embedded note processing in ContextProcessor

- Added support for processing embedded notes within markdown content, allowing for structured `<embedded_note>` blocks.
- Introduced new methods for building and formatting embedded note blocks, including error handling for missing notes.
- Enhanced logging to replace console statements with appropriate logging functions for better error tracking.
- Updated the `buildMarkdownContextContent` method to streamline content processing for markdown files.
- Added a new constant `EMBEDDED_NOTE_TAG` to manage embedded note structures.
2025-10-31 13:45:11 -07:00
Logan Yang
4a1debb441
Enhance ChatPersistenceManager to properly escape modelKey in YAML frontmatter (#1994)
- Introduced a new utility function `escapeYamlString` to handle escaping of special characters in modelKey values, ensuring valid YAML formatting.
- Updated the `modelKey` assignment in the generated note content to use the new escaping function.
- Added comprehensive tests to verify correct escaping for various special characters, including quotes, backslashes, and pipes, ensuring data integrity during save-load operations.
2025-10-31 12:08:42 -07:00
Logan Yang
bf7d84b021
Fix HyDE model (#1992)
* feat: Implement model validation and resolution for temperature overrides in ChatModelManager

- Added a new private method `isModelConfigValid` to validate model configurations against API key and entitlement requirements.
- Introduced `resolveModelForTemperatureOverride` to determine the active chat model, ensuring fallback to valid models while respecting user entitlements.
- Updated `getChatModelWithTemperature` to utilize the new model resolution logic for temperature overrides.
- Enhanced error handling for cases where no valid model is found.

* refactor: Remove deprecated chat models from BUILTIN_CHAT_MODELS constant

* feat: Add GROK_4_FAST model to BUILTIN_CHAT_MODELS constant

- Introduced a new chat model, GROK_4_FAST, with capabilities for vision and enabled status, enhancing the available options for users.
2025-10-31 10:33:11 -07:00
Logan Yang
c4027fa7a4
Update Corpus-in-Context and web search tool guide (#1988)
* Make inline citation permanent and add image guide for local search

- Removed the `enableInlineCitations` setting from the configuration and related components to simplify citation management.
- Updated citation functions to operate without the setting, ensuring consistent behavior across the codebase.
- Adjusted tests and documentation to reflect the removal of inline citation handling logic.
- Enhanced the `renderCiCMessage` and `processInlineCitations` functions for improved clarity and functionality.

* fix: Update web search tool description for clarity

- Revised the description of the `webSearch` tool to specify that it searches the internet for information only when explicitly requested by the user.
- Enhanced the instructions for using the `webSearch` tool to clarify when it should be utilized, ensuring users understand the intent required for web searches.
- Reintroduced the `createGetTagListTool` import to maintain functionality in the `builtinTools` module.

* fix: Enhance response handling in AgentPrompt integration tests

- Updated the response processing logic in the AgentPrompt integration tests to ensure that `fullResponse` is always a string, accommodating multimodal and array content.
- Implemented a function to extract text parts from arrays and handle various content types, improving the robustness of the tests.

* Remove websearch related prompt from modelAdapter

* fix: Update time query instructions in builtinTools

- Added detailed instructions for handling time queries, including the conversion of city or timezone names to UTC offsets.
- Clarified when to use the timezoneOffset parameter and emphasized the need for user clarification if the offset cannot be determined.
2025-10-31 10:06:18 -07:00
Yizong ZHOU
46d8aba8b2
Reduce images size in half in README without losing quality (#1966) 2025-10-30 16:58:32 -07:00
Logan Yang
bf9d2eca7c
fix: Add delay before emitting chat visibility to ensure React component readiness (#1987)
- Introduced a small delay using setTimeout to ensure the React component is fully ready to receive focus events when the chat window is opened. This change addresses potential timing issues with the focus event handling.
2025-10-30 16:02:10 -07:00
Logan Yang
81c15eae21
fix: Focus to chat input on opening chat window command (#1986)
- Introduced a wrapper function `handleFocusRegistration` to properly set the focus function state, avoiding issues with React's updater function interpretation.
- Updated the `FocusPlugin` to utilize the new focus registration method for better clarity and functionality.
2025-10-30 13:43:31 -07:00
QiyuanChen
856ca91b29
feat: Add SiliconFlow support for chat and embedding models (#1979) 2025-10-29 23:02:12 -07:00
Logan Yang
42b078075c
refactor: Simplify log file initialization and clear log on new chat (#1982)
- Updated `ensureInitialized` method in `LogFileManager` to start with an empty buffer, removing unnecessary file reading logic.
- Integrated log clearing functionality in `handleNewChat` to ensure a fresh start for each chat session.
2025-10-29 18:48:45 -07:00
Emt-lin
f62cb918da
feat: Add configurable send shortcut for chat messages. (#1968)
* feat: Add configurable send shortcut for chat messages.

* fix: Exclude opposite platform modifier in CMD_ENTER shortcut validation.

* refactor: Limit send shortcuts to ENTER and SHIFT_ENTER only.
2025-10-28 19:30:07 -07:00
Logan Yang
1a6f9c842b
Fix thinking block duplication text for openrouter thinking models (#1977)
* Fix thinking block duplication text for openrouter thinking models

* Update claude method name for thinking streamer
2025-10-28 19:16:48 -07:00
Logan Yang
e7d7d7f7f9
Update ChatPersistenceManager to enforce filename byte limit and handle ENAMETOOLONG errors (#1976)
- Changed the safe filename byte limit from 200 to 100 bytes to ensure compliance with filesystem constraints.
- Implemented fallback logic for filenames when encountering ENAMETOOLONG errors, allowing for minimal filenames with project prefixes.
- Added tests to verify the new fallback behavior and ensure proper handling of filenames with Cyrillic and mixed Unicode text.
- Enhanced error handling to update existing files when conflicts arise during save operations.
2025-10-28 18:12:53 -07:00
Logan Yang
d3a576beee
Enhance DBOperations with integrity check yielding (#1975)
- Introduced `INTEGRITY_CHECK_YIELD_INTERVAL` to improve the performance of the integrity check process by yielding control to the event loop at specified intervals, preventing UI freezing during long-running checks.
- Refactored the loop for checking file embeddings to utilize an index-based approach, allowing for periodic yielding.
- Added a new private method `yieldToEventLoop` to handle yielding control, ensuring a responsive user interface during operations that may take significant time.
- Updated documentation to include a description of the new yielding mechanism for better clarity on its purpose and functionality.
2025-10-28 17:35:48 -07:00
Logan Yang
ed884298ba
Integrate ProjectChainRunner and ChatManager with new layered context (#1973)
* Integrate ProjectChainRunner and ChatManager with new layered context

- Introduced `ProjectChainRunner` to automatically include project context in L1 via `ChatManager.getSystemPromptForMessage()`, simplifying the class structure by inheriting behavior from `CopilotPlusChainRunner`.
- Updated `ChatManager` to build system prompts that append project context when in project mode, ensuring seamless integration without special-case logic.
- Implemented null guards for project context to prevent erroneous output when context is unavailable.
- Refactored documentation to reflect changes in context handling and the new project chain runner functionality.

* Enhance ChatManager tests with project context mocks

- Added mocks for project context retrieval and system prompt settings in `ChatManager.test.ts` to facilitate testing without relying on actual project data.
- Improved test coverage for context-related functionalities by simulating project context and system prompt retrieval, ensuring robust testing of the ChatManager's behavior in various scenarios.
2025-10-28 17:08:37 -07:00
Logan Yang
05beb941eb
Context revamp (#1971)
* Implement context engine for a layered context

* Wire in new context library to LLM chain

- Updated the context engine to maintain a cumulative library of all context items (L2) without deduplication, enhancing cache stability.
- Introduced smart referencing in the user message (L3) to reference existing items in L2 by ID and include full content for new items.
- Improved the LayerToMessagesConverter to format and handle layered context envelopes for better debugging and clarity.
- Fixed critical bugs related to context deduplication that previously broke cache stability.
- Enhanced tests to validate the new context handling and ensure proper functionality across various scenarios.

* Enhance VaultQAChainRunner with tag extraction and inline citation detection

- Integrated tag extraction from user queries to improve context-aware retrieval.
- Implemented `hasInlineCitations` function to detect inline citation markers in responses.
- Updated message construction logic to utilize envelope-based context, enhancing the quality of AI interactions.
- Added comprehensive tests for the new inline citation detection functionality, ensuring robustness and accuracy.
- Improved logging for better debugging and traceability of the message processing flow.

Refactor VaultQAChainRunner to enhance context handling and citation instructions

- Integrated LayerToMessagesConverter for improved message construction, ensuring smart referencing and context preservation.
- Updated logging to reflect the use of the new converter in envelope-based context construction.
- Revised comments for clarity on context preparation and citation instructions.
- Minor adjustment in citation utility to improve regex handling for citation updates.

* Complete migration to envelope-based context in CopilotPlus and LLM chain runners

- Removed all legacy fallback paths, ensuring that context envelopes are now mandatory for operations in CopilotPlusChainRunner and LLMChainRunner.
- Enhanced image extraction to only pull from the active note, preventing unintended leaks from attached context notes.
- Fixed critical issues with context-in-context (CiC) formatting and added early envelope guards in VaultQAChainRunner to prevent silent failures.
- Updated message construction logic to utilize LayerToMessagesConverter for improved smart referencing and context preservation.
- Comprehensive tests added to validate the new envelope-based context handling and ensure robustness across all chain runners.

* Integrate Plus chain with context envelope

* Enhance AutonomousAgentChainRunner with envelope-based context integration

- Introduced `LayerToMessagesConverter` for improved message construction, ensuring system and user messages are derived from the context envelope.
- Implemented envelope validation to ensure context is available before processing.
- Updated message preparation logic to maintain consistency with the envelope-first architecture, preserving tool execution and multimodal support.
- Added comprehensive logging for envelope-based context construction to aid debugging.
- Documented changes in the context engineering documentation to reflect the new integration and design points.

* Support loading saved chat context to L2 context library

* Fix ChatPersistenceManager tests for context handling
2025-10-27 23:28:36 -07:00
Zero Liu
2480a0e42a
Support drag-n-drop files from file navbar (#1964)
* Support drag-n-drop from navbar

* Improve drop overlay

* Support multiple file

* Add canvas support

* Click badge to open file
2025-10-27 23:09:43 -07:00
Wenzheng Jiang
db97a8ace3
Prompt Improvement: Use getFileTree to explore ambiguous notes and folders (#1962)
* Prompt Improvement: Use getFileTree to explore ambiguous notes and folders.

* MInor

* fix: Clarify instructions for using getFileTree in note retrieval process
2025-10-26 13:46:34 -07:00
Logan Yang
2b5e07e61f
Stop condensing history in plus nonagent route (#1963) 2025-10-24 15:03:52 -07:00
Logan Yang
95247f01bd
3.1.1 (#1958) 2025-10-23 17:40:20 -07:00
Logan Yang
c394f4b95a
feat: Add UTF-8 byte length calculation and truncation functions (#1957)
- Implemented `getUtf8ByteLength` to calculate the byte length of strings in UTF-8, accommodating various character types including ASCII, Cyrillic, CJK, and emojis.
- Introduced `truncateToByteLimit` to truncate strings while respecting UTF-8 character boundaries, ensuring no multibyte characters are cut in half.
- Added unit tests for both functions to validate their behavior across different scenarios, including edge cases.
- Updated `ChatPersistenceManager` to utilize these functions for managing filename lengths, ensuring compliance with filesystem byte limits.
2025-10-23 17:22:23 -07:00
Logan Yang
b4fd925111
fix: Enhance error handling in AutonomousAgentChainRunner for local search and readNote tool calls (#1956) 2025-10-23 17:08:04 -07:00
Emt-lin
3a30b5bae6
feat: Improve error handling architecture across chain runners. (#1931)
* feat: Improve error handling architecture across chain runners.

# Conflicts:
#	src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts
#	src/chainFactory.ts
#	src/chainUtils.ts

* fix: Preserve correct content ordering in streaming messages

Fix two issues that could confuse users during streaming:
- Error blocks now inserted at correct stream position instead of always appending to end, preserving visual context
- Reset lastDisplayedContent between runs to prevent stale content from appearing in new responses

* fix: fix branch conflicts.

* fix: correctly dispose error block roots by parameterizing disposal helpers with registry.
2025-10-23 17:00:23 -07:00
Logan Yang
63a3f9fc5f
Add bedrock provider (#1955)
* Add Amazon Bedrock support

- Introduced new fields for Amazon Bedrock in `aiParams.ts` and `constants.ts`.
- Implemented API key and region validation in `utils.ts`.
- Created `BedrockChatModel` class for handling Bedrock-specific chat model interactions.
- Updated `ChatModelManager` to include Bedrock as a supported provider.
- Enhanced settings management to accommodate Bedrock API key and region.
- Added UI components for Bedrock model configuration in settings dialogs.
- Included unit tests for Bedrock chat model functionality.

This update expands the plugin's capabilities to integrate with Amazon Bedrock, allowing users to utilize its chat model features effectively.

* Support token count for bedrock

* Refactor Amazon Bedrock API key and region validation

- Simplified the API key validation logic in `utils.ts` to only require the API key, as the region defaults to "us-east-1" if not specified.
- Removed redundant checks for base URL and region in `chatModelManager.ts`, streamlining the endpoint construction process.

This update enhances the clarity and efficiency of the Bedrock integration, ensuring a smoother user experience.
2025-10-23 16:29:31 -07:00
Wenzheng Jiang
40d1badada
Add CRUD to Saved Memory (#1929) 2025-10-22 23:33:35 -07:00
Logan Yang
b90e80f509
Enable Openrouter thinking tokens (#1954)
* Fix sources css

* Enable thinking tokens for openrouter models

* Add reasoning effort for openrouter models

* Add unit tests for ThinkBlockStreamer to validate reasoning details handling and proper <think> tag placement

* Enable token count from openrouter models
2025-10-22 23:29:59 -07:00
Logan Yang
359fbc922b
Fix new note not discoverable in copilot chat (#1950) 2025-10-22 17:09:10 -07:00
Logan Yang
3c8420ae61
Stop rendering dataview result in AI response (#1947) 2025-10-21 11:50:14 -07:00
Logan Yang
12188cc9c9
Update README.md (#1946) 2025-10-20 18:07:09 -07:00
Zero Liu
3269943cc3
Improve custom command (#1942)
* Persist quick command configs

* upgrade langchain and fix gpt-5 in custom command

* Handle thinking in custom command

* code clean up

* Address cursor feedback
2025-10-20 17:52:50 -07:00
Yizong ZHOU
3ccbcda9ec
readme revamp: 1. added table of content 2. replace youtube videos wi… (#1915)
* readme revamp: 1. added table of content 2. replace youtube videos with updated screenshots.

* added create command, quick command and add selection to context section

* replace screenshots with bigger texts; add table of contents for usage section

* change screenshot resolution to correct distortion; modify a prompt to match the prompt in screenshot
2025-10-16 17:59:48 -07:00
Wenzheng Jiang
49a28e3067
Enhance canvas creation spec with with JSON Canvas Spec (#1928)
* Enhance canvas file structure and validation in constants and tools

* Minor tweak on vault search tool

* Update integration tests
2025-10-16 16:50:45 -07:00
Zero Liu
dbec5f90e2
Properly render pills in custom command (#1927)
* Properly render pills in custom command

* Support tools as well
2025-10-16 16:42:13 -07:00
Wenzheng Jiang
6c818106ad
Turn autosaveChat ON by default (#1923) 2025-10-12 23:57:36 -07:00
Zero Liu
4df8af76a0
Sort notes in typeahead menu by creation time (#1922) 2025-10-12 23:56:46 -07:00
Logan Yang
b3fb645279
Implement tag list builtin tool (#1919) 2025-10-12 19:59:01 -07:00
Logan Yang
cea72d61b1
Support dataview result in active note (#1918) 2025-10-12 19:05:55 -07:00
Wenzheng Jiang
231d020fea
Turn on memory feature by default (#1914)
* Turn on memory feature by default

* Ensure default values for enableRecentConversations and enableSavedMemory settings are applied correctly
2025-10-12 12:19:59 -07:00
Logan Yang
3b338b06bd
Update README.md (#1912) 2025-10-11 08:24:17 -07:00
Logan Yang
800a3dbbf9
3.1.0 (#1911)
* 3.1.0

* Add experimental to Memory setting title
2025-10-11 08:22:10 -07:00
Logan Yang
19e5092759
Hide autocomplete setting for now (#1909)
* Hide autocomplete setting for now

* Add todos to search v3 doc
2025-10-10 22:50:34 -07:00
Logan Yang
f2a163ff8b
Revert version to 3.0.3 in manifest.json (#1908) 2025-10-10 22:22:34 -07:00
Logan Yang
5568b70e0f
Merge 3.1.0 preview (#1906)
* Building persistent memory for copilot (#1848)

* Implement chat input v3 (#1794)

* Fix chat crash React 409 (#1849)

* Fix mobile typeahead menu size (#1853)

* Enhance progress bar and bug fix (#1814)

* feat: Add edit context for progress card.
* fix: When adding a model, do not set the key in the single model setting.
* fix: fix the width of the popover on mobile.

* Refactor Brevilabs API integration to use models base URL (#1855)

- Updated constants to switch from BREVILABS_API_BASE_URL to BREVILABS_MODELS_BASE_URL for model-related API calls.
- Adjusted ChatModelManager and EmbeddingManager to utilize the new models base URL for configuration settings.

* feat: Add new chat history popover. (#1850)

* feat: Add new chat history popover.

* feat: Add open source file button to chat history popover

- Add ArrowUpRight button to each chat history item
- Implement openChatSourceFile method in main.ts
- Add onOpenSourceFile callback prop chain through components
- Open chat files in new Obsidian tabs when clicked
- Optimize error handling to prevent duplicate notices

* feat: Implement localSearch CiC prompting flow in CopilotPlusChainRunner (#1856)

* Properly extract response text in UserMemoryManager (#1857)

* feat: Temporarily disable autocomplete features (#1858)

* chore: Update version to 3.1.0-preview-250927 in manifest.json

* Support space in typeahead trigger and improve search (#1859)

* More chat input enhancement (#1864)

* Fix dropdown color
* Fix badge border in light theme
* Increase search result number
* support paste image
* fix folder context

* Enhance ChatPersistenceManager with filename sanitization tests (#1865)

- Added tests to ensure proper sanitization of wiki link brackets and illegal characters in filenames when saving chat messages.
- Updated filename generation logic to handle empty sanitized topics by defaulting to 'Untitled Chat'.
- Refactored imports in ChatPersistenceManager for better organization.

* Update ApplyView accept button styles for improved visibility and interaction (#1866)

- Adjusted the positioning of the action button to be further from the bottom of the viewport for better accessibility.
- Increased the z-index to ensure the button is always on top of other elements.
- Added a shadow effect to enhance the button's visibility against the background.

* Expose add selection to chat context to free users (#1867)

* New chat input improvements (#1868)

* Improve search logic in at mention search
* Subscribe to file changes
* Detect changes for folders and tags
* Make context badge tooltip always show
* Add full note path in preview
* Fix enter being blocked when no search result

* Rebuild "Active Note" and more chat input improvement (#1873)

* Performance improvement and extend "active note" to note typeahead menu (#1875)

* Add better agent prompt logging (#1882)

* Add read tool (#1883)

* Add readNote tool for reading notes in chunks

* Enhance readNote tool to support dynamic note path display and linked note extraction

* Refine readNote tool instructions for improved clarity and efficiency in note content retrieval

* Add support for readNote tool: integrate emoji, format results, and refine instructions

* Add inline citation reminder functionality to user questions

* Add an alert when user hits the maxToken limit (#1884)

* Refactor AutonomousAgentChainRunner (#1887)

* Refactor AutonomousAgentChainRunner to enhance agent workflow, streamline context preparation, and improve response handling

* Enhance type safety in addChatHistoryToMessages by specifying message structure

* Improve max iteration limit message for clarity and formatting

* Refactor tool display name handling for improved clarity and maintainability

* Refactor tool call ID generation and visibility handling for improved clarity and uniqueness

* Fix agent tool call ID (#1890)

* Enhance tool call ID generation and improve readNote handling in prompts

Update readNote description for clarity and improve prompt instructions

* Update instructions for registerFileTreeTool to clarify usage guidelines

* Clarify instructions for readNote tool to improve context inference and handling of partial note titles

* Add token counter (#1889)

* Fix index rebuild on semantic search toggle (#1891)

* Update token counter label (#1892)

* Implement tag search v3 (#1893)

* feat: Enhance TieredLexicalRetriever to support tag-based retrieval and improve search scoring

- Added support for returning all matching tags in TieredLexicalRetriever.
- Introduced new options for tag terms and returnAllTags in the retriever.
- Updated FullTextEngine to prioritize tag matches and improve scoring for documents with tags.
- Enhanced tokenization to handle hierarchical tags and prevent splitting hyphenated tags.
- Improved handling of frontmatter tags in documents.
- Updated search tools to accommodate new tag handling features.
- Added tests to verify the functionality of tag-based retrieval and scoring improvements.

* fix: Improve logging for query expansion in SearchCore

* feat: Add explanation for non-tag matches in FullTextEngine search results

* feat: Enhance QueryExpander to preserve standalone terms in tag handling and improve term validation

* feat: Enhance QueryExpander and TieredLexicalRetriever to improve tag handling and standalone term extraction

* Update version to 3.1.0-preview-251006 in manifest.json

* Normalize tag queries for case-insensitive matching and improve search functionality (#1894)

* Implement merge retriever (#1896)

* Fix note read (#1897)

* Enhance note resolution logic and add tests for wiki-linked notes and basename matching

* Implement note resolution outcome types and enhance readNoteTool tests for ambiguous matches

* Add deriveReadNoteDisplayName function and enhance readNoteTool tests for edge cases

* Fix note tool UI freeze (#1898)

* Update how tags work in context (#1895)

* Enhance file creation instructions in modelAdapter and update tool usage guidelines in builtinTools (#1901)

- Added instructions for confirming folder existence before creating new files in modelAdapter.
- Updated custom prompt instructions in builtinTools to clarify the use of getFileTree for folder lookups when creating new notes.

* Do not add url context for youtube url (#1899)

* Update YouTube Script command and modal title to indicate Plus feature (#1903)

* Enhance ChatPersistenceManager to handle file save conflicts (#1904)

* Enhance ChatPersistenceManager to handle file save conflicts and improve epoch handling

* Fix type checking for existing files in ChatPersistenceManager to prevent errors

* Fix verify add (#1905)

- Rename "Verify" button to "Test" in Add Model dialog
- Make verification not required for adding model in Set Keys
- Upgrade chatAnthropic client to fix the Top P -1 error for Claude Opus models

* Enhance XML parsing to handle tool calls missing closing tags

---------

Co-authored-by: Wenzheng Jiang <jwzh.hi@gmail.com>
Co-authored-by: Zero Liu <zero@lumos.com>
Co-authored-by: Emt-lin <41323133+Emt-lin@users.noreply.github.com>
2025-10-10 21:38:50 -07:00
Logan Yang
9327c7a916
Fix tool calls UI freeze (#1846)
* Fix tool call UI freeze
* Have no sources section rendered when there's none
2025-09-24 00:56:21 -07:00
Logan Yang
301b67e8b1
Fix chat save with instant title notice (#1845)
* Fix chat save AI-generated title parsing
* Make chat save instant with AI generated topic property
2025-09-23 21:29:40 -07:00
600 changed files with 107887 additions and 29040 deletions

View file

@ -0,0 +1,63 @@
---
name: pr-pricing
description: 'Use this agent to size and price a PR (or list of PRs) based on the project''s PR pricing tiers. Provide PR numbers as the prompt. Example: "Price PRs #2100 #2101 #2102"'
model: sonnet
color: green
---
You are a PR pricing analyst for the Obsidian Copilot plugin. Your job is to size and price pull requests based on the project's pricing tiers.
## Pricing Tiers
### Sizing Principle
The most important factor is **user-facing impact** — what changes for the user, not how many files were touched.
- **Default to the lower end** of each range
- **Move toward the upper end** when the PR also includes tests, docs, edge case handling, or high polish
- When in doubt between two tiers, pick the lower one
### Tiers
| Size | Value | User-Facing Impact | Technical Scope |
| ---- | ------------ | --------------------------------------------------------- | ------------------------------------------------ |
| XS | $25-50 | Users unlikely to notice (typo, tooltip, minor styling) | Isolated 1-2 file change |
| S | $50-150 | Fixes an annoyance or adds a minor option | Small bug fix, config addition, no new workflows |
| M | $150-300 | Noticeable improvement to an existing workflow | Multi-file fix, simple feature, focused refactor |
| L | $300-600 | New capability users would highlight in a review | Standalone feature, new UI component or system |
| XL | $600-1,200 | Changes how users interact with a core part of the plugin | Large feature with new modules, core integration |
| XXL | $1,200-2,000 | Flagship feature, could justify a major version bump | New subsystem, deep cross-cutting integration |
### Reference PRs
| PR | Title | Size | Value | Rationale |
| ----- | ------------------------------------- | ---- | ----- | ---------------------------------------------------------------------------------- |
| #2003 | Refactor model API key handling | S | $50 | Internal cleanup, users see slightly better model filtering |
| #2087 | File status and think block state | M | $150 | Visible status badges + fix for a noticeable streaming UX bug |
| #2077 | Recent usage sorting for chat/project | M | $150 | Improves existing workflow with sort options, not a new capability |
| #1969 | System prompt management system | XL | $900 | New user-facing system for creating/managing system prompts, includes 9 test files |
## Your Process
For each PR number provided:
1. **Fetch PR details** using `gh pr view <number> --json title,additions,deletions,changedFiles,body`
2. **Check for tests/docs** using `gh pr view <number> --json files --jq '.files[].path'` and filter for test/doc files
3. **Assess user-facing impact** — this is the primary sizing factor:
- What does the user see or experience differently?
- Is this a new workflow, an improvement to an existing one, or invisible?
- Compare against the reference PRs for calibration
4. **Determine size tier** and pick a specific dollar value within the range
5. **Justify briefly** — one sentence on why this tier, referencing impact
## Output Format
Return a markdown table:
| PR | Title | Size | Value | Rationale |
| ----- | ----- | ---- | ----- | --------- |
| #XXXX | ... | M | $150 | ... |
With a **Total** row at the bottom.
Be conservative. Default to the lower end. Only move up with clear justification (tests, docs, high polish, significant UX impact).

View file

@ -0,0 +1,258 @@
---
name: prerelease
description: Use this agent to create a prerelease PR that triggers the automated GitHub Actions release workflow with the `--prerelease` flag set on the resulting GitHub Release. It bumps the version using a prerelease tag (e.g., `3.2.9-beta.1`), generates prerelease notes from merged PRs since the last release, updates RELEASES.md, and creates a PR whose title matches the prerelease semver pattern expected by the release workflow. Use when the user says "cut a prerelease", "create a beta", "release a release candidate", "publish an rc", or similar.
model: sonnet
color: yellow
---
You are a prerelease manager for the Copilot for Obsidian plugin. Your job is to create a prerelease PR that, when merged, publishes a GitHub Release marked as a prerelease so Obsidian's plugin browser does not offer it as a stable update to end users.
## How Prereleases Differ from Stable Releases
- The PR title is a prerelease semver: `X.Y.Z-<tag>.<N>`, e.g. `3.2.9-beta.1`, `3.3.0-rc.0`, `4.0.0-alpha.2`.
- The release workflow (`.github/workflows/release.yml`) detects the prerelease pattern and passes `--prerelease` to `gh release create`. The GitHub Release is marked as "prerelease" and Obsidian's plugin browser does not offer it as an automatic update.
- **Master's `manifest.json` is NEVER modified by a prerelease.** Obsidian's community plugin store reads `manifest.json` on master to decide which GitHub Release to serve, and it must always reflect the latest stable. The prerelease manifest lives in `manifest-beta.json` instead. `version-bump.mjs` enforces this: when `npm_package_version` is a prerelease, it writes only to `manifest-beta.json` and `versions.json`.
- `package.json` is updated by npm itself with the prerelease version. That's the source of truth the agent reads to know the current version.
- The release workflow swaps `manifest-beta.json` into `manifest.json` _inside the runner only_ before uploading release assets, so testers who download the prerelease's assets get a `manifest.json` carrying the prerelease version. The committed `manifest.json` on master stays pinned to the latest stable.
## Step-by-Step Process
### Step 0: Pre-flight Sanity Checks
Before doing any version bumping, validate the repo is releasable. Stop and surface a problem to the user rather than papering over it.
1. **Confirm clean working tree on master.**
```bash
git checkout master && git pull origin master
git status --porcelain
```
Any uncommitted state means another PR is in flight or a prior agent run left files behind. Stop and ask the user to clarify before continuing.
2. **Run the full project check.**
```bash
npm ci
npm run lint
npm run build
npm test
```
Any failure means master is broken. A prerelease published from a broken master will mislead testers about the state of the next stable release. Stop, report which step failed, and ask the user how to proceed.
3. **Inspect the built `main.js` bundle size.**
```bash
ls -lh main.js
```
If `main.js` is over 5 MB, surface the size to the user. Prereleases test the same release artifact stable users will get, so the same Sync Standard concern applies. Ask whether to ship the prerelease anyway or hold.
4. **Verify manifest integrity (both files).**
For the stable manifest:
```bash
node -p "JSON.stringify(require('./manifest.json'), null, 2)"
```
Confirm `isDesktopOnly` is declared and `minAppVersion` reflects what the code actually calls.
If `manifest-beta.json` exists (a previous prerelease is in flight), inspect it too:
```bash
[ -f manifest-beta.json ] && node -p "JSON.stringify(require('./manifest-beta.json'), null, 2)"
```
`manifest-beta.json`'s `minAppVersion` and other metadata must match `manifest.json`'s (we don't test different minimums in the prerelease channel).
5. **Assert master's `manifest.json.version` matches the latest stable GitHub Release.**
If master has drifted from the latest stable release tag, the prerelease will publish on top of a broken state. Catch the drift before doing anything else:
```bash
# Use /releases/latest which returns only the most-recent non-prerelease,
# non-draft release in a single call — works regardless of how many
# prereleases have accumulated since the last stable.
LATEST_STABLE=$(gh api repos/logancyang/obsidian-copilot/releases/latest -q .tag_name)
MASTER_VERSION=$(node -p "require('./manifest.json').version")
if [ "$LATEST_STABLE" != "$MASTER_VERSION" ]; then
echo "DRIFT: master manifest.json.version='$MASTER_VERSION' but latest stable Release='$LATEST_STABLE'. Stop." >&2
exit 1
fi
```
Stop and tell the user if this fails. Do not "fix" master's manifest.json inside a prerelease PR.
6. **Confirm there are merged PRs to prerelease.**
```bash
git describe --tags --abbrev=0
git log --oneline $(git describe --tags --abbrev=0)..HEAD | head
```
If empty, there is nothing new to test. Stop and tell the user.
Only proceed once all six checks pass.
### Step 1: Determine Prerelease Identity
Ask the user:
- **Tag** (`beta`, `rc`, `alpha`, etc.). Default `beta` if the user does not specify.
- **Base version target** (`prepatch`, `preminor`, `premajor`). What stable release is this prerelease leading up to?
- `prepatch` (most common): `3.2.8``3.2.9-beta.0`
- `preminor`: `3.2.8``3.3.0-beta.0`
- `premajor`: `3.2.8``4.0.0-beta.0`
- **Or is this an iteration on an existing prerelease line?** If the current version is already a prerelease (e.g. `3.2.9-beta.0`), use `prerelease` to bump only the prerelease counter: `3.2.9-beta.0``3.2.9-beta.1`.
### Step 2: Prepare the Branch
```bash
git checkout master
git pull origin master
```
Create a prerelease branch. Use a descriptive name that includes the prerelease identity:
```bash
git checkout -b prerelease/vX.Y.Z-<tag>.<N>
```
### Step 3: Bump the Version
Run the appropriate npm version command with `--preid` set to the chosen tag and `--no-git-tag-version` so npm does not create a tag locally (the release workflow handles tagging).
For a new prerelease line:
```bash
npm version <prepatch|preminor|premajor> --preid=<tag> --no-git-tag-version
```
For incrementing an existing prerelease:
```bash
npm version prerelease --preid=<tag> --no-git-tag-version
```
Examples:
- `3.2.8` + `npm version prepatch --preid=beta --no-git-tag-version``3.2.9-beta.0`
- `3.2.9-beta.0` + `npm version prerelease --preid=beta --no-git-tag-version``3.2.9-beta.1`
- `3.2.9-beta.5` + `npm version prerelease --preid=rc --no-git-tag-version``3.2.9-rc.0`
`version-bump.mjs` will update `manifest-beta.json` (creating it if it doesn't already exist by seeding from `manifest.json`) and `versions.json` to match. **It does NOT modify `manifest.json`.** After bumping, read the new version from `package.json` to use in subsequent steps.
### Step 4: Gather and Understand Merged PRs
Same as the stable release agent. Find the last tag (which may itself be a prerelease), list merged PRs since, and read each PR description for context.
```bash
git describe --tags --abbrev=0
gh pr list --state merged --base master --search "merged:>YYYY-MM-DD" --json number,title,author,labels --limit 500
```
If the last tag is a prerelease (e.g. `3.2.9-beta.0`), list PRs merged since that prerelease, not since the last stable. The prerelease note should reflect only what is new since the previous testing artifact.
### Step 5: Generate Prerelease Notes
Use the same `RELEASES.md` format as stable releases, with the following adjustments:
**Header format:**
```
# Copilot for Obsidian - Prerelease vX.Y.Z-<tag>.<N> 🧪
```
The `🧪` emoji signals testing intent. Other appropriate emoji: `🚧` (work in progress), `🔬` (research), `🐛` (bug-fix prerelease).
**Opening line:** State this is a prerelease intended for testers. Mention what is being tested.
> Example: _This is a beta release for testing the new Vault QA caching path before it ships in 3.2.9. Please report any indexing or query issues in Discord._
**Bullet list:** Same emoji + bold + cheerful style as stable releases, but be honest about what is unverified. If a feature has known sharp edges, say so explicitly.
**Do NOT** include the full "Improvements / Bug Fixes" PR roll-up that stable releases use unless the user asks for it. Prerelease notes should be short and testing-focused.
**Always include a "What to Test" section** with explicit bullets telling testers where to focus:
```markdown
## What to Test
- New behavior X: try Y workflow and confirm Z.
- Changed behavior W: confirm it still does what it used to do.
- Known sharp edges: list anything you suspect is unstable so testers don't waste time reporting it.
```
**Always include a "How to Install" section.** Most users don't know how to install a prerelease.
```markdown
## How to Install the Prerelease
1. Download `main.js`, `manifest.json`, and `styles.css` from this prerelease's GitHub release page.
2. Replace the same three files in your vault's `.obsidian/plugins/copilot/` folder.
3. Reload the plugin (Settings → Community Plugins → toggle Copilot off and back on, or restart Obsidian).
4. Report issues with the prerelease version number in the title so we can track them.
To return to the stable release: reinstall the plugin from Obsidian's community-plugin browser.
```
End with the same Troubleshoot footer as stable releases, and a `---` separator.
### Step 6: Update RELEASES.md
Prepend the prerelease entry at the top of `RELEASES.md`, right after the `# Release Notes` header line. Keep all existing entries intact.
When the corresponding stable release ships, that release's notes are appended above the prerelease entry. The prerelease entry stays in the file as a historical record.
### Step 7: Commit and Create PR
Stage all changed files. Note that `manifest-beta.json` is what gets touched for prereleases, NOT `manifest.json`:
```bash
git add package.json package-lock.json manifest-beta.json versions.json RELEASES.md
```
If `git status` shows `manifest.json` modified, something went wrong. `version-bump.mjs` should never touch `manifest.json` during a prerelease bump. Stop and tell the user.
Commit with message: `prerelease: vX.Y.Z-<tag>.<N>`
Push and create the PR:
```bash
git push -u origin prerelease/vX.Y.Z-<tag>.<N>
gh pr create --title "X.Y.Z-<tag>.<N>" --body "$(cat <<'EOF'
## Prerelease vX.Y.Z-<tag>.<N>
[Paste the prerelease notes content here]
---
Generated by the prerelease agent.
EOF
)"
```
**Critical**: The PR title MUST be exactly the prerelease semver string (e.g., `3.2.9-beta.1`) with no `v` prefix and nothing else. This pattern is what triggers the release workflow with `--prerelease` set.
### Step 8: Report Back
Share the PR URL with the user and summarize:
- What prerelease version was cut
- Which PRs are included (count and key features)
- The bundle size for awareness
- Reminder that the PR title is the prerelease tag and merging it publishes a prerelease GitHub Release
## Important Rules
- **Never force-push or modify existing release entries** in RELEASES.md.
- **Always start from latest master** — pull before branching.
- **The PR title must be a bare prerelease semver string** in the form `X.Y.Z-<tag>.<N>` (e.g., `3.2.9-beta.1`). No `v` prefix, no extra text. This pattern is what tells the release workflow to mark the GitHub Release as a prerelease.
- **Use the stable release agent, not this one, for stable releases.** A title like `3.2.9` (no prerelease suffix) goes to the stable agent's flow.
- **Read existing RELEASES.md entries** before writing — match the tone and format exactly. Prerelease entries should be visually distinguishable (🧪 emoji header, explicit "What to Test" section, "How to Install" section).
- **Be honest about what is unverified.** Prereleases exist to surface bugs, not to oversell stability. If you would not bet your reputation on a feature, say so in the notes.
- **Stop on any pre-flight failure.** Do not publish a prerelease from a master that fails lint/build/test or has an oversized bundle. Report and ask, do not paper over.
- **Do not silently change `manifest.minAppVersion` or `manifest.isDesktopOnly`** in a prerelease PR. Same rule as stable releases: those changes belong in dedicated PRs.
- **Never modify master's `manifest.json` from a prerelease.** It must always reflect the latest stable release. Obsidian's plugin store relies on this. Prerelease metadata goes into `manifest-beta.json` only.
- If `npm version` fails or `version-bump.mjs` doesn't run, manually update `manifest-beta.json` and `versions.json` to match the prerelease semver. Do NOT touch `manifest.json`.

242
.claude/agents/release.md Normal file
View file

@ -0,0 +1,242 @@
---
name: release
description: Use this agent to create a release PR that triggers the automated release workflow. It bumps the version, generates release notes from merged PRs since the last release, updates RELEASES.md, and creates a PR whose title matches the semver pattern expected by the release workflow. Use when the user says "create a release", "prepare a release", "bump version", or similar.
model: sonnet
color: green
---
You are a release manager for the Copilot for Obsidian plugin. Your job is to create a release PR that will trigger the automated GitHub Actions release workflow when merged.
## Release Workflow
The repository has a GitHub Actions workflow that triggers on PR merge to `master` when the PR title matches a semver pattern (e.g., `3.2.4`, `3.3.0`, `4.0.0`). Your job is to:
1. **Ask the user** whether this is a `patch`, `minor`, or `major` release
2. **Bump the version** using `npm version`
3. **Generate release notes** from merged PRs since the last release
4. **Update RELEASES.md** with the new release entry
5. **Create a PR** with the version number as the title
## Step-by-Step Process
### Step 0: Pre-flight Sanity Checks
Before doing any version bumping, validate the repo is releasable. Stop and surface a problem to the user rather than papering over it.
1. **Confirm clean working tree on master.**
```bash
git checkout master && git pull origin master
git status --porcelain
```
Any uncommitted state means another PR is in flight or a prior agent run left files behind. Stop and ask the user to clarify before continuing.
2. **Run the full project check.**
```bash
npm ci
npm run lint
npm run build
npm test
```
Any failure means master is broken and a release would publish a broken artifact. Stop, report which step failed, and ask the user how to proceed.
3. **Inspect the built `main.js` bundle size.**
```bash
ls -lh main.js
```
If `main.js` is over 5 MB, the release will trip Obsidian's Sync Standard warning and break sync for paying users. Stop, surface the exact size to the user, and ask whether to ship the release anyway or hold for a bundle-reduction PR first.
4. **Verify `manifest.json` integrity.**
```bash
node -p "JSON.stringify(require('./manifest.json'), null, 2)"
```
Confirm that:
- `isDesktopOnly` is declared (currently `false`; do not silently change this).
- `minAppVersion` matches the Obsidian APIs the code actually uses. If a commit since the last release introduced a call that needs a newer minimum, the `minAppVersion` bump belongs in its own dedicated PR with its own review window, not bundled inside this release PR. Stop and tell the user.
5. **Assert that `manifest.json.version` matches the latest stable GitHub Release.**
Obsidian's community plugin store reads `manifest.json` on master to decide which GitHub Release artifact to serve to installers. If master drifts away from the latest stable tag, installs break for everyone. Catch the drift loudly before doing anything else:
```bash
# Use /releases/latest which returns only the most-recent non-prerelease,
# non-draft release in a single call — works regardless of how many
# prereleases have accumulated since the last stable.
LATEST_STABLE=$(gh api repos/logancyang/obsidian-copilot/releases/latest -q .tag_name)
MASTER_VERSION=$(node -p "require('./manifest.json').version")
if [ "$LATEST_STABLE" != "$MASTER_VERSION" ]; then
echo "DRIFT: master manifest.json.version='$MASTER_VERSION' but latest stable Release='$LATEST_STABLE'. Stop." >&2
exit 1
fi
```
Stop and tell the user if this fails. Do not "fix" the drift by bumping `manifest.json` inside a release PR — that needs its own dedicated PR.
6. **Confirm there are merged PRs to release.**
```bash
git describe --tags --abbrev=0
git log --oneline $(git describe --tags --abbrev=0)..HEAD | head
```
If the diff is empty, there is nothing to release. Stop and tell the user.
Only proceed to Step 1 once all six checks pass.
### Step 1: Determine Release Type
Ask the user:
- **Patch** (bug fixes, small improvements)
- **Minor** (new features, enhancements)
- **Major** (breaking changes, major rewrites)
### Step 2: Prepare the Branch
```bash
git checkout master
git pull origin master
```
Create a release branch:
```bash
git checkout -b release/vX.Y.Z
```
### Step 3: Bump the Version
Run `npm version [patch|minor|major] --no-git-tag-version` to bump the version in `package.json`. This also triggers `version-bump.mjs` which updates `manifest.json` and `versions.json`.
**Important**: Use `--no-git-tag-version` to prevent npm from creating a git tag (the release workflow handles tagging).
After bumping, read the new version from `package.json` to use in subsequent steps.
### Step 4: Gather and Understand Merged PRs
Find the last release tag:
```bash
git describe --tags --abbrev=0
```
List all merged PRs since that tag (paginate to avoid missing entries if there are many):
```bash
gh pr list --state merged --base master --search "merged:>YYYY-MM-DD" --json number,title,author,labels --limit 500
```
If the output is exactly 500 entries, there may be more — repeat with an earlier `--search` cutoff or use `--limit 1000` and re-run.
Use the tag date as the cutoff. You can get it with:
```bash
git log -1 --format=%ai <tag>
```
**Read every PR's description** to understand what each change actually does. Don't rely on PR titles alone — they are often terse or developer-oriented. Fetch each PR's body:
```bash
gh pr view <NUMBER> --json body,title,author,labels
```
Read through all PR descriptions to understand:
- What user-facing behavior changed
- Why the change was made
- Any context that helps you write a better release note
This understanding is critical for writing accurate, user-facing release notes in the next step.
### Step 5: Generate Release Notes
Use your understanding of each PR's description and context to write release notes following the established style in `RELEASES.md`. Study the existing entries carefully:
**Format rules:**
- Header: `# Copilot for Obsidian - Release vX.Y.Z` followed by emoji (use 🚀 for minor/major, pick something fitting for patches)
- Opening line: A 1-2 sentence cheerful summary of the release highlights
- Bullet list of changes with emoji prefixes:
- Use relevant emoji for each item (🚀 new features, 🛠️ fixes, ⚡ performance, 🎨 UI, 📂 files, 🌐 web, 💡 models, etc.)
- **Bold the feature name** at the start of each bullet
- Write in plain, cheerful language — no technical jargon
- Attribute contributors with `(@username)` at the end of each bullet
- For sub-features, use indented bullets with their own emoji
- For minor/major releases, include a "More details in the changelog:" section with:
- `### Improvements` — list PRs as `- #NUMBER Description @author`
- `### Bug Fixes` — list fix PRs as `- #NUMBER Description @author`
- End with the Troubleshoot footer:
```
## Troubleshoot
- If models are missing, navigate to Copilot settings -> Models tab and click "Refresh Built-in Models".
- Please report any issue you see in the member channel!
```
- Add `---` separator after the Troubleshoot section
**Writing style:**
- Cheerful and enthusiastic, like you're excited to share good news
- No developer jargon — explain features from the user's perspective
- Use exclamation marks and emoji naturally (don't overdo it)
- Highlight what users can DO, not what changed internally
- Group related changes together under descriptive bullets
### Step 6: Update RELEASES.md
Prepend the new release entry at the top of `RELEASES.md`, right after the `# Release Notes` header line. Keep all existing entries intact.
### Step 7: Commit and Create PR
Stage all changed files:
```bash
git add package.json package-lock.json manifest.json versions.json RELEASES.md
```
Commit with message: `release: vX.Y.Z`
Push and create the PR:
```bash
git push -u origin release/vX.Y.Z
gh pr create --title "X.Y.Z" --body "$(cat <<'EOF'
## Release vX.Y.Z
[Paste the release notes content here]
---
Generated by the release agent.
EOF
)"
```
**Critical**: The PR title MUST be exactly the version number (e.g., `3.2.4`) with no `v` prefix and nothing else. This is what triggers the automated release workflow on merge.
### Step 8: Report Back
Share the PR URL with the user and summarize what was included in the release.
## Important Rules
- **Never force-push or modify existing release entries** in RELEASES.md
- **Always start from latest master** — pull before branching
- **The PR title must be a bare stable semver string** (e.g., `3.2.4`, not `v3.2.4` or `Release 3.2.4`). For prereleases, use the prerelease agent instead.
- **Include ALL merged PRs** since the last release — don't skip any
- **Attribute every change** to the correct contributor using their GitHub username
- **Read existing RELEASES.md entries** before writing — match the tone and format exactly
- If `npm version` fails or version-bump.mjs doesn't run, manually update `manifest.json` and `versions.json`
- **Do not silently change `manifest.minAppVersion` or `manifest.isDesktopOnly`** in a release PR. Those changes belong in their own dedicated PR with a separate review window so reviewers can scrutinize the compatibility impact.
- **Surface bundle-size growth in the release notes** if `main.js` grew significantly since the last release. Users notice, and reviewers do too.
- **Stop on any pre-flight failure.** Do not push a release PR for a master that fails lint/build/test, has an oversized bundle, or has an inconsistent manifest. Report and ask, do not paper over.
- **Stable releases delete `manifest-beta.json` automatically.** `version-bump.mjs` `git rm`s it when bumping to a stable version, on the rationale that the new stable supersedes any in-flight prerelease. This happens in the version-bump commit; nothing extra to do, but be aware that the diff will show the deletion.

View file

@ -1,3 +0,0 @@
node_modules/
main.js

View file

@ -1,52 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": ["@typescript-eslint", "tailwindcss"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:tailwindcss/recommended",
],
"parserOptions": {
"sourceType": "module",
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"react/prop-types": "off",
"react-hooks/exhaustive-deps": "error",
"tailwindcss/classnames-order": "error",
"tailwindcss/enforces-negative-arbitrary-values": "error",
"tailwindcss/enforces-shorthand": "error",
"tailwindcss/migration-from-tailwind-2": "error",
"tailwindcss/no-arbitrary-value": "off",
"tailwindcss/no-custom-classname": ["error"],
"tailwindcss/no-contradicting-classname": "error",
},
"overrides": [
{
"files": ["*.json", "*.jsonc", ".eslintrc"],
"parser": "jsonc-eslint-parser",
"rules": {
"jsonc/auto": "error",
},
},
],
"settings": {
"react": {
"version": "detect",
},
"tailwindcss": {
"callees": ["classnames", "clsx", "ctl", "cn", "cva"],
"config": "./tailwind.config.js",
"cssFiles": ["**/*.css", "!**/node_modules", "!**/.*", "!**/dist", "!**/build"],
},
},
}

View file

@ -7,11 +7,13 @@ assignees: ""
---
- [ ] Disable all other plugins besides Copilot **(required)**
- [ ] Screenshot of note + Copilot chat pane + dev console added **(required)**
- [ ] Log file generated via "Copilot: Create Log File" command or Settings -> Advanced -> Create Log File **(required)**
- [ ] Screenshot of note + Copilot chat pane + dev console added **(optional)**
Copilot version:
Model used:
(Bug report without the above will be closed)
(Bug reports missing the required items above will be closed)
**Describe how to reproduce**
A clear and concise description of what the bug is. Clear steps to reproduce the behavior

216
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,216 @@
# Release workflow: triggered when a PR targeting master is merged and its title
# is a semver string. Two formats are accepted:
# - Stable release: "X.Y.Z" (e.g. "3.2.3")
# - Prerelease: "X.Y.Z-<tag>.<N>" (e.g. "3.2.9-beta.1", "3.3.0-rc.0")
# Prerelease titles publish a GitHub Release marked as a prerelease.
# The PR title becomes the release tag and title; the PR body becomes the release notes.
#
# Non-semver PR titles (feature PRs, bug fixes, etc.) are silently ignored —
# the workflow runs but exits early without creating a release.
name: Release
on:
pull_request:
types: [closed]
branches:
- master
jobs:
release:
# Only proceed when the PR was actually merged (not just closed/abandoned).
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write # required to create GitHub releases and tags
attestations: write # required to publish artifact attestations
id-token: write # required for the workflow's OIDC token (used to sign attestations)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
# ── Step 1: Validate that the PR title is a semver (stable or prerelease) ──
# Exits without error when the title is not semver so non-release PRs
# pass silently. Sets outputs.version, outputs.is_release, and
# outputs.is_prerelease for downstream steps.
- name: Validate semver PR title
id: semver
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
if echo "$PR_TITLE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "PR title '$PR_TITLE' is a stable semver — proceeding with release."
echo "version=$PR_TITLE" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
elif echo "$PR_TITLE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z-]+\.[0-9]+$'; then
echo "PR title '$PR_TITLE' is a prerelease semver — proceeding with prerelease."
echo "version=$PR_TITLE" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "PR title '$PR_TITLE' is not semver (X.Y.Z or X.Y.Z-tag.N) — skipping release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
fi
# ── Step 2: Checkout the merge commit on master ─────────────────────────
- name: Checkout
if: steps.semver.outputs.is_release == 'true'
uses: actions/checkout@v4
with:
# Pin to the exact merge commit SHA so concurrent PRs merging to
# master don't cause this workflow to build the wrong code.
ref: ${{ github.event.pull_request.merge_commit_sha }}
# ── Step 3: Verify PR title version matches the source-of-truth manifest ──
# For stable releases the source of truth is manifest.json (which the
# Obsidian plugin store reads). For prereleases the source of truth is
# manifest-beta.json (manifest.json must stay pinned at the latest stable
# so the plugin store keeps serving installs correctly).
#
# For prereleases we ALSO assert that manifest.json on the merge commit
# still matches the latest stable GitHub Release tag. This defends against
# an old-style version-bump or hand edit accidentally re-poisoning master.
- name: Verify version matches manifest
if: steps.semver.outputs.is_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.semver.outputs.version }}
IS_PRERELEASE: ${{ steps.semver.outputs.is_prerelease }}
run: |
if [ "$IS_PRERELEASE" = "true" ]; then
MANIFEST_FILE="manifest-beta.json"
# Guard: master's manifest.json must still equal the latest stable Release tag.
# Use /releases/latest which (per GitHub API) returns only the most-recent
# non-prerelease, non-draft release in one call — no pagination concerns
# even if many prereleases have accumulated since the last stable.
LATEST_STABLE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" -q .tag_name 2>/dev/null || true)
STABLE_MANIFEST_VERSION=$(node -p "require('./manifest.json').version")
if [ -z "$LATEST_STABLE" ]; then
echo "Could not determine latest stable GitHub Release tag; aborting to avoid poisoning manifest.json"
exit 1
fi
if [ "$STABLE_MANIFEST_VERSION" != "$LATEST_STABLE" ]; then
echo "manifest.json on merge commit ('$STABLE_MANIFEST_VERSION') drifted from latest stable Release ('$LATEST_STABLE')."
echo "A prerelease PR must not modify manifest.json. Refusing to publish."
exit 1
fi
echo "manifest.json drift check passed: $STABLE_MANIFEST_VERSION matches latest stable."
else
MANIFEST_FILE="manifest.json"
fi
if [ ! -f "$MANIFEST_FILE" ]; then
echo "Expected $MANIFEST_FILE to exist for this release type but it does not."
exit 1
fi
MANIFEST_VERSION=$(node -p "require('./$MANIFEST_FILE').version")
if [ "$VERSION" != "$MANIFEST_VERSION" ]; then
echo "Version mismatch: PR title='$VERSION', $MANIFEST_FILE='$MANIFEST_VERSION'"
exit 1
fi
echo "Version check passed: $VERSION (verified against $MANIFEST_FILE)"
# ── Step 4: Set up Node.js ───────────────────────────────────────────────
- name: Setup Node.js 22.x
if: steps.semver.outputs.is_release == 'true'
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm
# ── Step 5: Install dependencies ────────────────────────────────────────
- name: Install dependencies
if: steps.semver.outputs.is_release == 'true'
run: npm ci
# ── Step 6: Build the plugin ─────────────────────────────────────────────
- name: Build
if: steps.semver.outputs.is_release == 'true'
run: npm run build
# ── Step 7: Write PR body to a file (avoids shell injection) ─────────────
# Using an environment variable to pass the PR body prevents special
# characters (backticks, quotes, dollar signs, etc.) from being
# interpreted by the shell.
- name: Write release notes to file
if: steps.semver.outputs.is_release == 'true'
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: printf '%s' "$PR_BODY" > /tmp/release-notes.md
# ── Step 8: Check if release already exists (idempotency guard) ─────────
- name: Check if release already exists
if: steps.semver.outputs.is_release == 'true'
id: check_existing
env:
VERSION: ${{ steps.semver.outputs.version }}
run: |
if gh release view "$VERSION" > /dev/null 2>&1; then
echo "::warning::Release $VERSION already exists — skipping."
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
# ── Step 9: Prepare release-asset manifest ──────────────────────────────
# For a prerelease, the manifest.json asset uploaded to the GitHub
# Release must carry the prerelease version (so testers who sideload
# the assets get a manifest matching what they downloaded). We swap
# manifest-beta.json into manifest.json's place IN THE RUNNER only —
# this never gets pushed back to master, so the committed manifest.json
# stays pinned to the latest stable.
- name: Prepare release-asset manifest
if: steps.semver.outputs.is_release == 'true' && steps.semver.outputs.is_prerelease == 'true'
run: |
cp manifest-beta.json manifest.json
echo "Release-asset manifest.json (prerelease):"
cat manifest.json
# ── Step 10: Generate build provenance attestation ──────────────────────
# Cryptographically signs main.js / manifest.json / styles.css with the
# workflow's OIDC identity and publishes the attestation to Sigstore's
# public transparency log. Anyone can later verify a downloaded asset
# was actually built by this workflow on this commit with:
# gh attestation verify main.js --owner logancyang --repo logancyang/obsidian-copilot
#
# Runs AFTER the prerelease manifest swap so the attested manifest.json
# exactly matches the file uploaded to the GitHub Release.
- name: Generate artifact attestation
if: steps.semver.outputs.is_release == 'true' && steps.check_existing.outputs.exists != 'true'
uses: actions/attest-build-provenance@v2
with:
subject-path: |
main.js
manifest.json
styles.css
# ── Step 11: Create GitHub Release ───────────────────────────────────────
# --target pins the tag to the exact merge commit SHA so concurrent
# merges cannot cause the release tag to point at a different commit.
# When the PR title is a prerelease semver (X.Y.Z-tag.N), pass --prerelease
# so Obsidian's plugin browser does not offer it as a stable update.
# Artifacts: main.js, manifest.json, styles.css
- name: Create GitHub Release
if: steps.semver.outputs.is_release == 'true' && steps.check_existing.outputs.exists != 'true'
env:
VERSION: ${{ steps.semver.outputs.version }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
IS_PRERELEASE: ${{ steps.semver.outputs.is_prerelease }}
run: |
PRERELEASE_FLAG=""
if [ "$IS_PRERELEASE" = "true" ]; then
PRERELEASE_FLAG="--prerelease"
fi
gh release create "$VERSION" \
--target "$MERGE_SHA" \
--title "$VERSION" \
--notes-file /tmp/release-notes.md \
$PRERELEASE_FLAG \
main.js \
manifest.json \
styles.css

1
.gitignore vendored
View file

@ -27,6 +27,7 @@ data.json
# Claude configuration
.claude/settings.local.json
.claude/worktrees/
# Development session tracking
TODO.md

7
.husky/pre-commit Normal file → Executable file
View file

@ -1,6 +1 @@
# .husky/pre-commit
prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown
git update-index --again
# Add linting
npm run lint
npx nano-staged

115
AGENTS.md
View file

@ -12,6 +12,7 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L
- **NEVER RUN `npm run dev`** - The user will handle all builds manually
- `npm run build` - Production build (TypeScript check + minified output)
- `npm run test:vault` - macOS only. Installs deps, builds, symlinks `main.js` / `manifest.json` / `styles.css` from the current worktree into `$COPILOT_TEST_VAULT_PATH/.obsidian/plugins/copilot/`, then reloads the plugin via the Obsidian CLI. Requires the user-level env var `COPILOT_TEST_VAULT_PATH` to be set to a vault that has been opened in Obsidian at least once. Use this when the user asks you to load the plugin into their test vault — it replaces manual build + copy + reload.
### Code Quality
@ -27,6 +28,38 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L
- `npm run test:integration` - Run integration tests (requires API keys)
- Run single test: `npm test -- -t "test name"`
### Obsidian CLI (Live Testing)
The Obsidian desktop app includes a CLI for plugin development. Use the full path:
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian <command>
```
**Plugin reload** (after `npm run build`):
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian plugin:reload id=copilot
```
**Console debugging** (requires attaching debugger first):
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:debug on
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:console limit=30
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:console level=error limit=10
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:errors
```
**Other useful dev commands**:
- `dev:dom selector=<css>` — Query DOM elements
- `dev:screenshot path=<file>` — Take a screenshot
- `eval code=<js>` — Execute JS in the app context
- `plugin:disable id=copilot` / `plugin:enable id=copilot`
Run `obsidian help` for the full command list.
## High-Level Architecture
### Core Systems
@ -109,7 +142,7 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L
## Message Management Architecture
For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE.md`](./docs/MESSAGE_ARCHITECTURE.md).
For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE.md`](./designdocs/MESSAGE_ARCHITECTURE.md).
### Core Classes and Flow
@ -192,6 +225,15 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
- `logError()` for errors
- Import from logger: `import { logInfo, logWarn, logError } from "@/logger"`
### CSS & Styling
- **NEVER edit `styles.css` directly** - This is a generated file
- **Source file**: `src/styles/tailwind.css` - Edit this file for custom CSS
- **Build process**: `npm run build:tailwind` compiles `src/styles/tailwind.css``styles.css`
- **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes)
- **Custom CSS**: Add custom styles to `src/styles/tailwind.css` after the `@import` statements
- After editing CSS, always run `npm run build` to regenerate `styles.css`
## Testing Guidelines
- Unit tests use Jest with TypeScript support
@ -200,6 +242,17 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
- Test files adjacent to implementation (`.test.ts`)
- Use `@testing-library/react` for component testing
### Avoiding Deep Dependency Chains in Tests
This codebase has deep transitive import chains (e.g. a utility → cache → searchUtils → embeddingManager → brevilabsClient → plusUtils → Modal). Importing any module in this chain from a test requires mocking the entire tree, which is brittle and verbose.
**Rules for new code:**
1. **Pass data, not services** — If a function only needs a string (like `outputFolder`), accept it as a parameter. Don't give it access to the entire settings singleton.
2. **Singletons at the edges only**`getSettings()`, `PDFCache.getInstance()`, `BrevilabsClient.getInstance()` should only be called in top-level orchestration (constructors, main entry points). Inner functions receive what they need as parameters.
3. **Pure logic in leaf modules** — Extract testable logic into small files with minimal imports. The orchestration file (which has heavy imports) calls the leaf function and passes in the dependencies. See `src/tools/convertedDocOutput.ts` as an example.
4. **Litmus test before writing a function** — "Can I test this by calling it directly with plain arguments?" If the answer is no because of an import, that dependency should be a parameter instead.
## Development Session Planning
### Using TODO.md for Session Management
@ -258,13 +311,70 @@ The TODO.md should be:
- Settings are versioned - migrations may be needed
- Local model support available via Ollama/LM Studio
- Rate limiting is implemented for all API calls
- For technical debt and known issues, see [`TECHDEBT.md`](./docs/TECHDEBT.md)
- For technical debt and known issues, see [`TECHDEBT.md`](./designdocs/todo/TECHDEBT.md)
- For current development session planning, see [`TODO.md`](./TODO.md)
## User-Facing Documentation
- **When modifying user-facing behavior** (new features, changed settings, removed functionality), **update the corresponding doc in `docs/`**. The doc filenames match their topics (e.g., `llm-providers.md` for provider changes, `agent-mode-and-tools.md` for tool changes).
- Docs are written for non-technical users — no source code references, explain behavior and concepts.
- If a change affects multiple docs, update all of them.
- If you're unsure which doc to update, check `docs/index.md` for the full list with descriptions.
### AWS Bedrock Usage
**IMPORTANT**: When using AWS Bedrock, always use **cross-region inference profile IDs** for better reliability and availability:
- **Global** (recommended): `global.anthropic.claude-sonnet-4-5-20250929-v1:0`
- Routes to any commercial AWS region automatically
- Best for reliability and performance
- **US**: `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **EU**: `eu.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **APAC**: `apac.anthropic.claude-sonnet-4-5-20250929-v1:0`
**Avoid regional model IDs** (without prefix): `anthropic.claude-sonnet-4-5-20250929-v1:0`
- These only work in specific regions and often fail
- Not recommended for production use
**References:**
- [AWS Bedrock Cross-Region Inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html)
- [Supported Inference Profiles](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html)
### Obsidian Plugin Environment
- **Global `app` variable**: In Obsidian plugins, `app` is a globally available variable that provides access to the Obsidian API. It's automatically available in all files without needing to import or declare it.
### Picking the right `document` / `window` (popout-window safety)
Obsidian supports pop-out windows. The plugin loads in the main window but views can live in any window. Picking the wrong `Document` / `Window` produces stale references, off-screen popovers, listeners on the wrong window, or DOM nodes that never render. Use this decision order:
1. **`element.doc` / `element.win`** — preferred. Obsidian augments every `Node` with `.doc: Document` and `.win: Window` that always reflect the element's current owner. Use whenever you have any DOM node in scope (a ref, an event target, a component's container, a `Range`'s `startContainer`).
- `containerRef.current?.doc.addEventListener(...)`
- `range.startContainer.win.innerWidth`
- `editor.getRootElement()?.doc`
2. **`global activeDocument` / `activeWindow`** — fallback only. These point to whichever window is _focused right now_. Correct semantics for actions that follow user focus (e.g., the AddImageModal file picker; selectionchange registration at plugin load), but wrong when the action belongs to a specific view (a chat in a popout while the user clicks back to the main window).
3. **`document` / `window` globals** — almost always wrong. They are aliases for the main window even when the user is interacting with a popout. Avoid in new code. If you find yourself reaching for them, it's a sign the surrounding code should be taking a `Document`/`Window` parameter or deriving from a DOM ref.
4. **`element.ownerDocument`** — works (standard DOM), but prefer `.doc` for consistency with the codebase. They return the same `Document` for any mounted `HTMLElement`. `.doc` is shorter and typed non-nullable.
**Listeners that may outlive a window migration:** capture the `Document` / `Window` at registration and remove on the same one:
```ts
const doc = containerRef.current?.doc;
if (!doc) return;
doc.addEventListener("keydown", handler);
return () => doc.removeEventListener("keydown", handler);
```
Do **not** rely on `activeDocument` at registration _and_ removal — it can shift between the two calls if focus moves.
**View migrated to a new window:** for a view that owns React or other long-lived renderers, register `this.containerEl.onWindowMigrated((win) => { ... })` in `onOpen`. The callback fires when Obsidian reparents the element into a different window's document. Tear down and rebuild the renderer there so it captures the new window. Save the returned destroy function and call it in `onClose` to avoid leaks. `CopilotView` is the canonical example — it unmounts and recreates the React root on migration so Lexical re-binds to the popout's window.
**Cross-realm `instanceof`:** popout windows have their own `Element`, `MouseEvent`, etc., so standard `instanceof` checks fail across windows. Use Obsidian's `element.instanceOf(HTMLElement)` and `event.instanceOf(MouseEvent)` when checking type across realms.
**Tests (jsdom):** `jest.setup.js` polyfills `Node.doc` / `Node.win` so plugin code using these properties works under jsdom. Don't add `instanceof` guards that depend on the Obsidian-augmented globals without considering the test environment.
### Architecture Migration Notes
- **SharedState Removed**: The legacy `src/sharedState.ts` has been completely removed
@ -278,3 +388,4 @@ The TODO.md should be:
- Non-project chats stored in default repository
- Backwards compatible - loads existing messages from ProjectManager cache
- Zero configuration required - works automatically
- Check @tailwind.config.js to understand what tailwind css classnames are available

279
CLAUDE.md
View file

@ -1,280 +1,3 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
Copilot for Obsidian is an AI-powered assistant plugin that integrates various LLM providers (OpenAI, Anthropic, Google, etc.) with Obsidian. It provides chat interfaces, autocomplete, semantic search, and various AI-powered commands for note-taking and knowledge management.
## Development Commands
### Build & Development
- **NEVER RUN `npm run dev`** - The user will handle all builds manually
- `npm run build` - Production build (TypeScript check + minified output)
### Code Quality
- `npm run lint` - Run ESLint checks
- `npm run lint:fix` - Auto-fix ESLint issues
- `npm run format` - Format code with Prettier
- `npm run format:check` - Check formatting without changing files
- **Before PR:** Always run `npm run format && npm run lint`
### Testing
- `npm run test` - Run unit tests (excludes integration tests)
- `npm run test:integration` - Run integration tests (requires API keys)
- Run single test: `npm test -- -t "test name"`
## High-Level Architecture
### Core Systems
1. **LLM Provider System** (`src/LLMProviders/`)
- Provider implementations for OpenAI, Anthropic, Google, Azure, local models
- `LLMProviderManager` handles provider lifecycle and switching
- Stream-based responses with error handling and rate limiting
- Custom model configuration support
2. **Chain Factory Pattern** (`src/chainFactory.ts`)
- Different chain types for various AI operations (chat, copilot, adhoc prompts)
- LangChain integration for complex workflows
- Memory management for conversation context
- Tool integration (search, file operations, time queries)
3. **Vector Store & Search** (`src/search/`)
- `VectorStoreManager` manages embeddings and semantic search
- `ChunkedStorage` for efficient large document handling
- Event-driven index updates via `IndexManager`
- Multiple embedding providers support
4. **UI Component System** (`src/components/`)
- React functional components with Radix UI primitives
- Tailwind CSS with class variance authority (CVA)
- Modal system for user interactions
- Chat interface with streaming support
- Settings UI with versioned components
5. **Message Management Architecture** (`src/core/`, `src/state/`)
- **MessageRepository** (`src/core/MessageRepository.ts`): Single source of truth for all messages
- Stores each message once with both `displayText` and `processedText`
- Provides computed views for UI display and LLM processing
- No complex dual-array synchronization
- **ChatManager** (`src/core/ChatManager.ts`): Central business logic coordinator
- Orchestrates MessageRepository, ContextManager, and LLM operations
- Handles message sending, editing, regeneration, and deletion
- Manages context processing and chain memory synchronization
- **Project Chat Isolation**: Maintains separate MessageRepository per project
- Automatically detects project switches via `getCurrentMessageRepo()`
- Each project has its own isolated message history
- Non-project chats use `defaultProjectKey` repository
- **ChatUIState** (`src/state/ChatUIState.ts`): Clean UI-only state manager
- Delegates all business logic to ChatManager
- Provides React integration with subscription mechanism
- Replaces legacy SharedState with minimal, focused approach
- **ContextManager** (`src/core/ContextManager.ts`): Handles context processing
- Processes message context (notes, URLs, selected text)
- Reprocesses context when messages are edited
6. **Settings Management**
- Jotai for atomic settings state management
- React contexts for feature-specific state
7. **Plugin Integration**
- Main entry: `src/main.ts` extends Obsidian Plugin
- Command registration system
- Event handling for Obsidian lifecycle
- Settings persistence and migration
- Chat history loading via pending message mechanism
### Key Patterns
- **Single Source of Truth**: MessageRepository stores each message once with computed views
- **Clean Architecture**: Repository → Manager → UIState → React Components
- **Context Reprocessing**: Automatic context updates when messages are edited
- **Computed Views**: Display messages for UI, LLM messages for AI processing
- **Project Isolation**: Each project maintains its own MessageRepository instance
- **Error Handling**: Custom error types with detailed interfaces
- **Async Operations**: Consistent async/await pattern with proper error boundaries
- **Caching**: Multi-layer caching for files, PDFs, and API responses
- **Streaming**: Real-time streaming for LLM responses
- **Testing**: Unit tests adjacent to implementation, integration tests for API calls
## Message Management Architecture
For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE.md`](./docs/MESSAGE_ARCHITECTURE.md).
### Core Classes and Flow
1. **MessageRepository** (`src/core/MessageRepository.ts`)
- Single source of truth for all messages
- Stores `StoredMessage` objects with both `displayText` and `processedText`
- Provides computed views via `getDisplayMessages()` and `getLLMMessages()`
- No complex dual-array synchronization or ID matching
2. **ChatManager** (`src/core/ChatManager.ts`)
- Central business logic coordinator
- Orchestrates MessageRepository, ContextManager, and LLM operations
- Handles all message CRUD operations with proper error handling
- Synchronizes with chain memory for conversation history
- **Project Chat Isolation Implementation**:
- Maintains `projectMessageRepos: Map<string, MessageRepository>` for project-specific storage
- `getCurrentMessageRepo()` automatically detects current project and returns correct repository
- Seamlessly switches between project repositories when project changes
- Creates new empty repository for each project (no message caching)
3. **ChatUIState** (`src/state/ChatUIState.ts`)
- Clean UI-only state manager
- Delegates all business logic to ChatManager
- Provides React integration with subscription mechanism
- Replaces legacy SharedState with minimal, focused approach
4. **ContextManager** (`src/core/ContextManager.ts`)
- Handles context processing (notes, URLs, selected text)
- Reprocesses context when messages are edited
- Ensures fresh context for LLM processing
5. **ChatPersistenceManager** (`src/core/ChatPersistenceManager.ts`)
- Handles saving and loading chat history to/from markdown files
- Project-aware file naming (prefixes with project ID)
- Parses and formats chat content for storage
- Integrated with ChatManager for seamless persistence
## Code Style Guidelines
### MAJOR PRINCIPLES
- **ALWAYS WRITE GENERALIZABLE SOLUTIONS**: Never add edge-case handling or hardcoded logic for specific scenarios (like "piano notes" or "daily notes"). Solutions must work for all cases.
- **NEVER MODIFY AI PROMPT CONTENT**: Do not update, edit, or change any AI prompts, system prompts, or model adapter prompts unless explicitly asked to do so by the user
- **Avoid hardcoding**: No hardcoded folder names, file patterns, or special-case logic
- **Configuration over convention**: If behavior needs to vary, make it configurable, not hardcoded
- **Universal patterns**: Solutions should work equally well for any folder structure, naming convention, or content type
### TypeScript
- Strict mode enabled (no implicit any, strict null checks)
- Use absolute imports with `@/` prefix: `import { ChainType } from "@/chainFactory"`
- Prefer const assertions and type inference where appropriate
- Use interface for object shapes, type for unions/aliases
### React
- Functional components only (no class components)
- Custom hooks for reusable logic
- Props interfaces defined above components
- Avoid inline styles, use Tailwind classes
### General
- File naming: PascalCase for components, camelCase for utilities
- Async/await over promises
- Early returns for error conditions
- **Always add JSDoc comments** for all functions and methods
- Organize imports: React → external → internal
- **Avoid language-specific lists** (like stopwords or action verbs) - use language-agnostic approaches instead
### Logging
- **NEVER use console.log** - Use the logging utilities instead:
- `logInfo()` for informational messages
- `logWarn()` for warnings
- `logError()` for errors
- Import from logger: `import { logInfo, logWarn, logError } from "@/logger"`
## Testing Guidelines
- Unit tests use Jest with TypeScript support
- Mock Obsidian API for plugin testing
- Integration tests require API keys in `.env.test`
- Test files adjacent to implementation (`.test.ts`)
- Use `@testing-library/react` for component testing
## Development Session Planning
### Using TODO.md for Session Management
**IMPORTANT**: When working on a development session, maintain a comprehensive `TODO.md` file that serves as the central plan and tracker:
1. **Session Goal**: Define the high-level objective at the start
2. **Task Tracking**:
- List all completed tasks with [x] checkboxes
- Track pending tasks with [ ] checkboxes
- Group related tasks into logical sections
3. **Architecture Decisions**: Document key design choices and rationale
4. **Progress Updates**: Keep the TODO.md updated as tasks complete
5. **Testing Checklist**: Include verification steps for the session
The TODO.md should be:
- The single source of truth for session progress
- Updated frequently as work progresses
- Clear enough that another developer can understand what was done
- Comprehensive enough to serve as a migration guide
### Structure Example:
```markdown
# Development Session TODO
## Session Goal
[Clear statement of what this session aims to achieve]
## Completed Tasks ✅
- [x] Task description with key details
- [x] Another completed task
## Pending Tasks 📋
- [ ] Next task to work on
- [ ] Future enhancement
## Architecture Summary
[Key design decisions and rationale]
## Testing Checklist
- [ ] Functionality verification
- [ ] Performance checks
```
## Important Notes
- The plugin supports multiple LLM providers with custom endpoints
- Vector store requires rebuilding when switching embedding providers
- Settings are versioned - migrations may be needed
- Local model support available via Ollama/LM Studio
- Rate limiting is implemented for all API calls
- For technical debt and known issues, see [`TECHDEBT.md`](./docs/TECHDEBT.md)
- For current development session planning, see [`TODO.md`](./TODO.md)
### Obsidian Plugin Environment
- **Global `app` variable**: In Obsidian plugins, `app` is a globally available variable that provides access to the Obsidian API. It's automatically available in all files without needing to import or declare it.
### Architecture Migration Notes
- **SharedState Removed**: The legacy `src/sharedState.ts` has been completely removed
- **Clean Architecture**: New architecture follows Repository → Manager → UIState → UI pattern
- **Single Source of Truth**: All messages stored once in MessageRepository with computed views
- **Context Always Fresh**: Context is reprocessed when messages are edited to ensure accuracy
- **Chat History Loading**: Uses pending message mechanism through CopilotView → Chat component props
- **Project Chat Isolation**: Each project now has completely isolated chat history
- Automatic detection of project switches via `ProjectManager.getCurrentProjectId()`
- Separate MessageRepository instances per project ID
- Non-project chats stored in default repository
- Backwards compatible - loads existing messages from ProjectManager cache
- Zero configuration required - works automatically
@AGENTS.md

View file

@ -53,6 +53,63 @@ In the case of Copilot for Obsidian, you will need to:
Try to be descriptive in your branch names and pull requests. Happy coding!
#### Fast Iteration with `npm run test:vault` (macOS)
If you work across multiple worktrees or just want one command to build and load the plugin into a test vault, use `npm run test:vault`. It runs `npm install`, builds, symlinks `main.js` / `manifest.json` / `styles.css` from the worktree into the vault's `.obsidian/plugins/copilot/` folder, and reloads the plugin in Obsidian via its CLI.
**One-time setup:**
1. Create or pick a vault dedicated to plugin testing and open it in Obsidian at least once so `.obsidian/` is created.
2. Enable community plugins in that vault (Settings → Community plugins → Turn on).
3. Set an env var pointing at the vault path. Add this to `~/.zshrc`, `~/.bashrc`, or `~/.config/fish/config.fish`:
```bash
export COPILOT_TEST_VAULT_PATH="$HOME/Obsidian/CopilotTestVault"
```
**Per change:**
From any worktree, run:
```bash
npm run test:vault
```
The script installs deps, builds the plugin, symlinks the build artifacts into the vault, then calls `plugin:enable` and `plugin:reload` on the Obsidian CLI. If Obsidian isn't running, the symlinks are still in place — start Obsidian and the new build will load.
Because the script symlinks files (not the worktree root), the vault's plugin `data.json` (settings, chat history) stays vault-local and is preserved across worktrees and rebuilds.
Requires macOS with Obsidian installed at `/Applications/Obsidian.app`.
## Commit Signing
Commits to `master` must be signed and verified by GitHub. The easiest path is SSH signing using your existing SSH key.
1. Configure git to sign with your SSH key:
```bash
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
```
Replace `id_ed25519.pub` with the path to your own public key if different.
2. Register the same key as a **Signing Key** on GitHub at https://github.com/settings/ssh/new. Set "Key type" to `Signing Key` (this is separate from an Authentication Key, even if it's the same key).
3. Confirm your commit email matches a verified email on your GitHub account at https://github.com/settings/emails. Otherwise commits show as Unverified even when signed.
4. Verify locally and on GitHub:
```bash
git commit --allow-empty -m "test signing"
git log --show-signature -1
```
After pushing, the commit on github.com should display a green **Verified** badge.
If you already use GPG, set `gpg.format openpgp` instead and register the GPG public key at https://github.com/settings/gpg/new. Commits merged via the GitHub web UI are auto-signed by GitHub and don't need this setup.
## Prompt Testing
If you are making prompt changes, make sure to run the integration tests using the following steps:

257
README.md
View file

@ -22,7 +22,19 @@ The Ultimate AI Assistant for Your Second Brain
</a>
</p>
Copilot for Obsidian is your ultimate invault AI assistant with chat-based vault search, web and youtube support, powerful context processing and ever-expanding agentic capabilities—all within Obsidians highly customizable workspace.
## The What
_Copilot for Obsidian_ is your invault AI assistant with chat-based vault search, web and YouTube support, powerful context processing, and ever-expanding agentic capabilities within Obsidian's highly customizable workspace - all while keeping your data under **your** control.
## The Why
Today's AI giants want **you trapped**: your data on their servers, prompts locked to their models, and switching costs that keep you paying. When they change pricing, shut down features, or terminate your account, you lose everything you built.
We are building the opposite. Our goal is to create a portable agentic experience with no provider lock-in. **Data is always yours.** Use whatever LLM you like. Imagine that a brand new model drops, you run it on your own hardware, and it already knows about you (_long-term memory_), knows how to run _the same commands and tools_ you have defined over time (as just markdown files), and becomes the thought partner and assistant that you _own_. This is AI that grows with you, not a subscription you're hostage to.
This is the future we believe in. If you share this vision, please support this project!
## Key Features
- **🔒 Your data is 100% yours**: Local search and storage, and full control of your data if you use self-hosted models.
- **🧠 Bring Your Own Model**: Tap any OpenAI-compatible or local model to uncover insights, spark connections, and create content.
@ -36,18 +48,43 @@ Copilot for Obsidian is your ultimate invault AI assistant with chat-based va
<em>Copilot's Agent can call the proper tools on its own upon your request.</em>
</p>
<p align="center">
<img src="./images/product-ui-screenshot.jpeg" alt="Product UI screenshot" width="800"/>
<img src="./images/product-ui-screenshot.png" alt="Product UI screenshot" width="800"/>
</p>
## Copilot V3 is a New Era 🔥
## Table of Contents
After months of hard work, we have revamped the codebase and adopted a new paradigm for our agentic infrastructure. It opens the door for easier addition of agentic tools (MCP support coming). We will provide a new version of the documentation soon. Here is a couple of new things that you cannot miss!
- [The What](#the-what)
- [The Why](#the-why)
- [Key Features](#key-features)
- [Copilot v4: Agent Mode, Reimagined 🚀](#copilot-v4-agent-mode-reimagined-)
- [Why People Love It ❤️](#why-people-love-it-)
- [Get Started](#get-started)
- [Install Obsidian Copilot](#install-obsidian-copilot)
- [Set API Keys](#set-api-keys)
- [Usage](#usage)
- [Free User](#free-user)
- [**Chat Mode: reference notes and discuss ideas with Copilot**](#chat-mode-reference-notes-and-discuss-ideas-with-copilot)
- [**Vault QA Mode: chat with your entire vault**](#vault-qa-mode-chat-with-your-entire-vault)
- [Copilot's Command Palette](#copilots-command-palette)
- [**Relevant Notes: notes suggestions based on semantic similarity and links**](#relevant-notes-notes-suggestions-based-on-semantic-similarity-and-links)
- [Copilot Plus/Believer](#copilot-plusbeliever)
- [**Get Precision Insights From a Specific Time Window**](#get-precision-insights-from-a-specific-time-window)
- [**Agent Mode: Autonomous Tool Calling**](#agent-mode-autonomous-tool-calling)
- [**Understand Images in Your Notes**](#understand-images-in-your-notes)
- [**One Prompt, Every Source—Instant Summaries from PDFs, Videos, and Web**](#one-prompt-every-sourceinstant-summaries-from-pdfs-videos-and-web)
- [**Need Help?**](#need-help)
- [**FAQ**](#faq)
- [**🙏 Thank You**](#-thank-you)
- [**Copilot Plus Disclosure**](#copilot-plus-disclosure)
- [**Authors**](#authors)
- FOR ALL USERS: You can do vault search out-of-the-box **without building an index first** (Indexing is still available but optional behind the "Semantic Search" toggle in QA settings).
- FOR FREE USERS: Image support and chat context menu are available to all users starting from v3.0.0!
- FOR PLUS USERS: Autonomous agent is available with vault search, web search, youtube, composer and soon a lot other tools!
## Copilot v4: Agent Mode, Reimagined 🚀
Read the [Changelog](https://github.com/logancyang/obsidian-copilot/releases/tag/3.0.0).
Our biggest leap yet. **Copilot v4** lets you run the most capable coding agents available — **opencode**, **Claude Code**, or **Codex** — natively inside your vault, tuned for knowledge work and entirely on your terms. Bring your own agent, keep every note on your device, and let it plan, search, and act across your Second Brain. No lock-in, no compromise.
**Join Supporter to experience the magic of Copilot v4 now!**
👉 **[Discover Copilot v4 →](https://www.obsidiancopilot.com/v4)**
## Why People Love It ❤️
@ -55,164 +92,163 @@ Read the [Changelog](https://github.com/logancyang/obsidian-copilot/releases/tag
- *"Since discovering Copilot, my writing process has been completely transformed. Conversing with my own articles and thoughts is the most refreshing experience Ive had in decades.”* - Mat QV, Writer
- *"Copilot has transformed our family—not just as a productivity assistant, but as a therapist. I introduced it to my nontechnical wife, Mania, who was stressed about our daughters upcoming exam; within an hour, she gained clarity on her mindset and next steps, finding calm and confidence."* - @screenfluent, A Loving Husband
## **Get Started in 5 Minutes**
## Get Started
### FREE Product Features
### Install Obsidian Copilot
**🔌 Install Copilot in Community Plugins in Obsidian**
1. Open **Obsidian → Settings → Community plugins**.
2. Turn off **Safe mode** (if enabled).
3. Click **Browse**, search for **“Copilot for Obsidian”**.
4. Click **Install**, then **Enable**.
**🔑 Set Up Your AI Model (API Key)**
### Set API Keys
- To start using Copilot AI features, you'll need access to an AI model of your choice.
**Free User**
1. Go to **Obsidian → Settings → Copilot → Basic** and click **Set Keys**.
2. Choose your AI provider(s) (e.g., **OpenRouter, Gemini, OpenAI, Anthropic, Cohere**) and paste your API key(s). **OpenRouter is recommended.**
**Copilot Plus/Believer**
1. Copy your license key at your [dashboard](https://www.obsidiancopilot.com/en/dashboard). _Dont forget to join our wonderful Discord community!_
2. Go to **Obsidian → Settings → Copilot → Basic** and paste the key into in the **Copilot Plus** card.
## Usage
### Free User
#### **Chat Mode: reference notes and discuss ideas with Copilot**
Use `@` to add context and chat with your note.
<p align="center">
<a href="https://www.youtube.com/watch?v=mzMbiamzOqM" target="_blank">
<img src="./images/AI-Model-Setup.png" alt="AI Model API Key" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/Add-Context.png" alt="Chat Mode" width="700">
</p>
**📖** **Chat Mode: Summarize Specific Notes**
Ask Copilot:
- 🧠 **Use When:** You want to reference specific notes or folders, generate content, or talk through ideas with Copilot like a knowledgeable thought partner.
- 💭 **In `Chat` mode, ask Copilot:**
> _"Summarize [[Meeting Notes March]] and create a follow-up task list based on notes in {projects}."_
> _Summarize [[Q3 Retrospective]] and identify the top 3 action items for Q4 based on the notes in {01-Projects}._
<p align="center">
<a href="https://www.youtube.com/watch?v=idit7nCqEs0" target="_blank">
<img src="./images/Chat-Mode.png" alt="Chat Mode" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/Chat-Mode.png" alt="Chat Mode" width="700">
</p>
**📖** **Vault QA Mode: Chat With Your Entire Vault**
#### **Vault QA Mode: chat with your entire vault**
- 🧠 **Use When:** You want to search your vault for patterns, ideas, or facts without knowing exactly where the information is stored.
Ask Copilot:
- 💭 **In `Vault QA` mode, ask Copilot:**
> _"What insights can I gather about the benefits of journaling from all of my notes?"_
- 💡 **Tip:** Replace _the benefits of journaling_ with any topic mentioned in your notes to get more precise results.
> _What are the recurring themes in my research regarding the intersection of AI and SaaS?_
<p align="center">
<a href="https://www.youtube.com/watch?v=hBLMWE8WRFU" target="_blank">
<img src="./images/Vault-Mode.png" alt="Vault Mode" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/Vault-Mode.png" alt="Vault Mode" width="700">
</p>
**📖 Edit and Apply with One Click**
#### Copilot's Command Palette
- 🧠 **Use When:** You want to quickly fix grammar, spelling or wording directly in your notes—without switching tabs or manually rewriting.
Copilot's Command Palette puts powerful AI capabilities at your fingertips. Access all commands in chat window via `/` or via
right-click menu on selected text.
- 💭 **Select the text** and **edit with one RIGHT click**
**Add selection to chat context**
- 💡 **Tip:** Set up and customize your right-click menu with common actions you use often, like _"Summarize"_, _"Simplify Language"_, or _"Translate to Formal Tone"_—so you can apply them effortlessly while you write.
Select text and add it to context. Recommend shortcut: `ctrl/cmd + L`
<p align="center">
<a href="https://www.youtube.com/watch?v=hSmRnmEVoec" target="_blank">
<img src="./images/One-Click-Commands.png" alt="One-Click Commands" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/Add-Selection-to-Context.png" alt="Add Selection to Context" width="700">
</p>
**📖 Automate your workflow with the Copilot Prompt Palette**
**Quick Command**
- 🧠 **Use When:** You want to speed up repetitive tasks like summarizing, rewriting, or translating without typing full prompts every time.
- 💭 Type / to use Prompt Palette
- 💡 **Tip:** Create shortcuts for your most-used actions—like _"Translate to Spanish"_ or _"Draft a blog post outline"_—and trigger them instantly with typing / !
Select text and apply action without opening chat. Recommend shortcut: `ctrl/cmd + K`
<p align="center">
<a href="https://www.youtube.com/watch?v=9YzY2OJ54wM" target="_blank">
<img src="./images/Prompt-Palette.png" alt="Prompt Palette" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/Quick-Command.png" alt="Quick Command" width="700">
</p>
**📖 Stay in flow with the Relevant Notes**
**Edit and Apply with One Click**
- 🧠 **Use When:** You're working on a note and want to pull in context or insights from related notes—without breaking your focus.
- 💭 Appears automatically when there's useful related content.
- 💡 **Tip:** Use it to quickly reference past research, ideas, or decisions—no need to search or switch tabs.
Select text and edit with one RIGHT click.
<p align="center">
<a href="https://www.youtube.com/watch?v=qapQD7jD3Uk" target="_blank">
<img src="./images/Relevant-Notes.png" alt="Relevant Notes" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/One-Click-Commands.png" alt="One-Click Commands" width="700">
</p>
### Level Up with Copilot Plus and Beyond
**Create your Command**
Create commands and workflows in `Settings → Copilot → Command → Add Cmd`.
<p align="center">
<img src="./images/Create-Command.png" alt="Create Command" width="700">
</p>
**Command Palette in Chat**
Type `/` to use Command Palette in chat window.
<p align="center">
<img src="./images/Prompt-Palette.png" alt="Prompt Palette" width="700">
</p>
#### **Relevant Notes: notes suggestions based on semantic similarity and links**
Appears automatically when there's useful related content and links.
Use it to quickly reference past research, ideas, or decisions—no need to search or switch tabs.
<p align="center">
<img src="./images/Relevant-Notes.png" alt="Relevant Notes" width="700">
</p>
### Copilot Plus/Believer
Copilot Plus brings powerful AI agentic capabilities, context-aware actions and seamless tool integration—built to elevate your knowledge work in Obsidian.
🆙 **Upgrade to Copilot Plus**
#### **Get Precision Insights From a Specific Time Window**
First, go to https://www.obsidiancopilot.com/en to subscribe to Copilot Plus. Then, set up Copilot Plus License Key in Obsidian.
In agent mode, ask copilot:
> _What did I do last week?_
<p align="center">
<a href="https://www.youtube.com/watch?v=pPfWKZnNYhA" target="_blank">
<img src="./images/Copilot-Plus-Setup.png" alt="Copilot Plus Setup" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/Time-Based-Queries.png" alt="Time-Based Queries" width="700">
</p>
❔Community is at the heart of everything we build. Join us on Discord for updates, priority support, and a voice in shaping the best AI products for your experience.
#### **Agent Mode: Autonomous Tool Calling**
Copilot's agent automatically calls the right tools—no manual commands needed. Just ask, and it searches the web, queries your vault, and combines insights seamlessly.
Ask Copilot in agent mode:
> _Research web and my vault and draft a note on AI SaaS onboarding best practices._
<p align="center">
<img src="./images/discord-support.png" alt="Discord support screenshot" width="700"/>
<img src="./images/Agent-Mode.png" alt="Agent Mode" width="700">
</p>
**📖 Get Precision Insights From a Specific Time Window**
#### **Understand Images in Your Notes**
- 🧠 **Use When:** You want to quickly review tasks, notes, or ideas from a specific time range without manually digging through files.
Copilot can analyze images embedded in your notes—from wireframes and diagrams to screenshots and photos. Get detailed feedback, suggestions, and insights based on visual content.
- 💭 **In Chat mode, ask Copilot:**
Ask Copilot to analyze your wireframes:
> _"Give me a recap of everything I captured last week."_
- 💡 **Tip:** Try variations like _"Summarize my highlights from August 11 through August 22"_ for even more insights.
> _Analyze the wireframe in [[UX Design - Mobile App Wireframes]] and suggest improvements for the navigation flow._
<p align="center">
<a href="https://www.youtube.com/watch?v=sXP2sjvrqtI" target="_blank">
<img src="./images/Time-Based-Queries.png" alt="Time-Based Queries" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/Note-Image.png" alt="Image Understanding" width="700">
</p>
**📖 One Prompt, Every Source—Instant Summaries from PDFs, Videos, and Web**
#### **One Prompt, Every Source—Instant Summaries from PDFs, Videos, and Web**
- 🧠 **Use When:** You want to combine information from multiple formats—documents, videos, web pages, and images—into one concise, actionable summary.
In agent mode, ask Copilot
- 💭 **In PLUS mode, ask Copilot:**
> \*Compare the information about [Agent Memory] from this youtube video: [URL], this PDF [file], and @web[search results]. Start with your
> "Please write a short intro of Kiwi birds based on the following information I collected about this animal.
> @youtube Summarize [](https://www.youtube.com/watch?v=tZ2jm_UPc6c&t=417s)[https://www.youtube.com/watch?v=tZ2jm_UPc6c&t=417s](https://www.youtube.com/watch?v=ABTfc5wUT1U)
> in a short paragraph.
> @websearch where can I find Kiwi birds?
> Summarize https://www.doc.govt.nz/nature/native-animals/birds/birds-a-z/kiwi/ in 300 words.“
- 🛠️ **Add PDFs and Images as Context to Enrich Your Learning**
- 💡 _Tip: For large PDFs, reference specific sections to focus the AI's attention._
conclusion in bullet points in your response*
<p align="center">
<a href="https://www.youtube.com/watch?v=WXoOZmMSHVE" target="_blank">
<img src="./images/One-Prompt-Every-Source.png" alt="One Prompt, Every Source" width="700" height="394">
</a><br>
<em>Click the image to watch the video on YouTube</em>
<img src="./images/One-Prompt-Every-Source.png" alt="One Prompt, Every Source" width="700">
</p>
# **💡 Need Help?**
## **Need Help?**
- Check the [documentation](https://www.obsidiancopilot.com/en/docs) for setup guides, how-tos, and advanced features.
- Watch [Youtube](https://www.youtube.com/@loganhallucinates) for walkthroughs.
@ -231,7 +267,7 @@ First, go to https://www.obsidiancopilot.com/en to subscribe to Copilot Plus. Th
- ☑Clearly describe the feature, why it matters, and how it would help
- ☑Submit your feature request [here](https://github.com/logancyang/obsidian-copilot/issues/new?template=feature_request.md)
# **🙋‍♂️ FAQ**
## **FAQ**
<details>
<summary><strong>Why isnt Vault search finding my notes?</strong></summary>
@ -294,13 +330,16 @@ Special thanks to our top sponsors: @mikelaaron, @pedramamini, @Arlorean, @dashi
Copilot Plus is a premium product of Brevilabs LLC and it is not affiliated with Obsidian. It offers a powerful agentic AI integration into Obsidian. Please check out our website [obsidiancopilot.com](https://obsidiancopilot.com/) for more details!
- An account and payment are required for full access.
- Copilot Plus requires network use to faciliate the AI agent.
- Copilot Plus does not access your files without your consent.
- Copilot Plus collect server-side telemetry to improve the product. Please see the privacy policy on the website for more details.
- Copilot Plus requires network use to facilitate the AI agent.
- **Privacy & Data Handling**:
- **Free tier**: Your messages and notes are sent only to your configured LLM provider (OpenAI, Anthropic, Google, etc.). Nothing goes to Brevilabs servers.
- **Plus tier**: Messages go to your configured LLM provider. File conversions (PDF, DOCX, EPUB, images, etc.) are processed by Brevilabs servers only when you explicitly trigger these features via `@` commands.
- **Processing vs. Retention**: We process your data to deliver the feature you requested, then discard it. No message content, file uploads, or documents are retained on our servers after processing.
- **User ID**: A randomly generated UUID is sent with Plus API requests for service delivery (license abuse prevention, rate limiting) but is not used for user tracking, profiling, or analytics.
- Please see the privacy policy on the website for more details.
- The frontend code of Copilot plugin is fully open-source. However, the backend code facilitating the AI agents is close-sourced and proprietary.
- We offer a full refund if you are not satisfied with the product within 14 days of your purchase, no questions asked.
## **Authors**
Brevilabs Team | Email: logan@brevilabs.com | X/Twitter: [@logancyang](https://twitter.com/logancyang)

2238
RELEASES.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,25 @@
// __mocks__/obsidian.js
/* eslint-disable no-undef */
import yaml from "js-yaml";
import { parse as parseYamlString } from "yaml";
// Per-test overrides set via the exported `__setRequestUrlImpl` helper.
// Default: empty success response. Tests that exercise network paths should
// install their own implementation.
let requestUrlImpl = jest.fn().mockResolvedValue({
status: 200,
text: "",
json: undefined,
arrayBuffer: new ArrayBuffer(0),
headers: {},
});
module.exports = {
// Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests
normalizePath: jest.fn().mockImplementation((p) => p),
moment: jest.requireActual("moment"),
requestUrl: (...args) => requestUrlImpl(...args),
__setRequestUrlImpl: (impl) => {
requestUrlImpl = impl;
},
Vault: jest.fn().mockImplementation(() => {
return {
getMarkdownFiles: jest.fn().mockImplementation(() => {
@ -30,7 +47,7 @@ module.exports = {
isDesktop: true,
},
parseYaml: jest.fn().mockImplementation((content) => {
return yaml.load(content);
return parseYamlString(content);
}),
Modal: class Modal {
constructor() {
@ -49,7 +66,7 @@ module.exports = {
},
})),
ItemView: jest.fn().mockImplementation(function () {
this.containerEl = document.createElement("div");
this.containerEl = window.document.createElement("div");
this.onOpen = jest.fn();
this.onClose = jest.fn();
this.getDisplayText = jest.fn().mockReturnValue("Mock View");
@ -58,7 +75,7 @@ module.exports = {
}),
Notice: jest.fn().mockImplementation(function (message) {
this.message = message;
this.noticeEl = document.createElement("div");
this.noticeEl = window.document.createElement("div");
this.hide = jest.fn();
}),
TFile: jest.fn().mockImplementation(function (path) {
@ -67,6 +84,10 @@ module.exports = {
this.basename = this.name.replace(/\.[^/.]+$/, "");
this.extension = path.split(".").pop();
}),
TFolder: jest.fn().mockImplementation(function (path) {
this.path = path || "";
this.name = this.path.split("/").pop() || "";
}),
WorkspaceLeaf: jest.fn().mockImplementation(function () {
this.view = null;
this.setViewState = jest.fn();
@ -76,7 +97,7 @@ module.exports = {
};
// Mock the global app object
global.app = {
window.app = {
vault: {
getAbstractFileByPath: jest.fn().mockReturnValue({
name: "test-file.md",
@ -84,6 +105,8 @@ global.app = {
}),
read: jest.fn().mockResolvedValue("test content"),
modify: jest.fn().mockResolvedValue(undefined),
getMarkdownFiles: jest.fn().mockReturnValue([]),
getAllLoadedFiles: jest.fn().mockReturnValue([]),
},
workspace: {
getActiveFile: jest.fn().mockReturnValue(null),
@ -91,4 +114,11 @@ global.app = {
openFile: jest.fn().mockResolvedValue(undefined),
}),
},
metadataCache: {
getFirstLinkpathDest: jest.fn().mockReturnValue(null),
getFileCache: jest.fn().mockReturnValue(null),
},
fileManager: {
trashFile: jest.fn().mockResolvedValue(undefined),
},
};

View file

@ -0,0 +1,193 @@
# BedrockChatModel Tool Calling Implementation
## Status: ✅ IMPLEMENTED
Native tool/function calling support has been added to the custom `BedrockChatModel` class for Agent mode.
---
## Summary
The `BedrockChatModel` now supports LangChain's native tool calling via `model.bindTools(tools)`, enabling it to work with Agent mode just like `ChatOpenAI`, `ChatAnthropic`, and `ChatGoogleGenerativeAI`.
---
## Implementation Details
### 1. `bindTools()` Method
```typescript
bindTools(tools: StructuredToolInterface[]): BedrockChatModel {
const bound = Object.create(this) as BedrockChatModel;
bound.boundTools = tools;
return bound;
}
```
Creates a new instance with tools bound, following LangChain's pattern.
### 2. Tool Format Conversion
```typescript
private convertToolsToClaude(tools: StructuredToolInterface[]): any[] {
return tools.map((tool) => {
let inputSchema: any = { type: "object", properties: {} };
if (tool.schema) {
inputSchema = isInteropZodSchema(tool.schema)
? toJsonSchema(tool.schema)
: tool.schema;
}
return {
name: tool.name,
description: tool.description || "",
input_schema: inputSchema,
};
});
}
```
Uses LangChain's `isInteropZodSchema` and `toJsonSchema` for proper schema conversion.
### 3. Request Body with Tools
Tools are included in the request payload when bound:
```typescript
if (this.boundTools && this.boundTools.length > 0) {
payload.tools = this.convertToolsToClaude(this.boundTools);
}
```
### 4. ToolMessage Handling
`buildRequestBody` handles `ToolMessage` (tool results) as `tool_result` content blocks:
```typescript
if (messageType === "tool") {
const toolMessage = message as ToolMessage;
conversation.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: toolMessage.tool_call_id,
content: toolResultContent,
},
],
});
}
```
### 5. AIMessage with Tool Calls
`buildRequestBody` handles `AIMessage` with `tool_calls` as `tool_use` content blocks:
```typescript
if (toolCalls && toolCalls.length > 0) {
const contentBlocks: ContentBlock[] = [];
// Add text if present
// Add tool_use blocks for each tool call
for (const tc of toolCalls) {
contentBlocks.push({
type: "tool_use",
id: tc.id || `tool_${Date.now()}`,
name: tc.name,
input: tc.args as Record<string, unknown>,
});
}
}
```
### 6. Non-Streaming Tool Call Extraction
`_generate` extracts tool calls from Claude's response:
```typescript
private extractToolCalls(data: any): any[] | undefined {
if (!Array.isArray(data?.content)) return undefined;
const toolUseBlocks = data.content.filter(
(block: any) => block.type === "tool_use"
);
if (toolUseBlocks.length === 0) return undefined;
return toolUseBlocks.map((block: any) => ({
id: block.id,
name: block.name,
args: block.input || {},
type: "tool_call" as const,
}));
}
```
### 7. Streaming Tool Call Chunks
`processStreamEvent` emits `tool_call_chunks` for LangChain's concat mechanism:
```typescript
private extractToolCallChunk(event: any): { id?: string; index: number; name?: string; args?: string } | null {
// content_block_start with tool_use - initial tool call info
if (event.type === "content_block_start" && event.content_block?.type === "tool_use") {
return {
id: event.content_block.id,
index: event.index ?? 0,
name: event.content_block.name,
args: "",
};
}
// content_block_delta with input_json_delta - partial tool args
if (event.type === "content_block_delta" && event.delta?.type === "input_json_delta") {
return {
index: event.index ?? 0,
args: event.delta.partial_json || "",
};
}
return null;
}
```
Tool call chunks are emitted as `AIMessageChunk` with `tool_call_chunks`:
```typescript
const toolCallChunk = this.extractToolCallChunk(innerEvent);
if (toolCallChunk) {
const messageChunk = new AIMessageChunk({
content: "",
response_metadata: chunkMetadata,
tool_call_chunks: [toolCallChunk],
});
deltaChunks.push(new ChatGenerationChunk({ message: messageChunk, text: "" }));
}
```
---
## Testing
```typescript
const model = new BedrockChatModel({
modelId: "us.anthropic.claude-3-5-sonnet-20241022-v2:0",
apiKey: "...",
endpoint: "...",
streamEndpoint: "...",
});
const tools = [
{
name: "get_weather",
description: "Get weather for a location",
schema: z.object({ location: z.string() }),
},
];
const boundModel = model.bindTools(tools);
const response = await boundModel.invoke([new HumanMessage("What's the weather in Tokyo?")]);
console.log(response.tool_calls); // Should have tool call
```
---
## Reference
- [Claude Tool Use on Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/tool-use.html)
- [Anthropic Tool Use Guide](https://docs.anthropic.com/en/docs/build-with-claude/tool-use)
- [LangChain ChatAnthropic](https://github.com/langchain-ai/langchainjs/tree/main/libs/langchain-anthropic)

View file

@ -0,0 +1,473 @@
# Context Engineering - Layered Prefix System
## Table of Contents
1. [Purpose](#purpose)
2. [First-Principles Goals](#first-principles-goals)
3. [Current Architecture (Verified)](#current-architecture-verified)
4. [Example Chat Walkthrough](#example-chat-walkthrough)
5. [Chain Runner Envelope Usage](#chain-runner-envelope-usage)
6. [Strengths](#strengths)
7. [Known Gaps](#known-gaps)
8. [Improvement Roadmap](#improvement-roadmap)
9. [Testing and Observability](#testing-and-observability)
10. [References](#references)
---
## Purpose
The context envelope system is the canonical prompt-construction pipeline for chat turns.
It exists to guarantee:
- reproducible prompt assembly,
- minimal duplication across L2/L3/L4,
- safe compaction behavior on large context,
- and cache-friendly request prefixes for major providers.
This document is an implementation audit and roadmap based on current production code.
---
## First-Principles Goals
### 1. Reproducibility
For the same turn inputs, envelope construction should be deterministic and byte-stable.
### 2. Token Efficiency
Context artifacts should appear once in canonical form (or as references), not duplicated across layers.
### 3. Prefix Cache Stability
Stable content should stay in early request tokens (L1/L2) so implicit provider caching has maximal hit rate.
### 4. Compaction Safety
Compaction must preserve answerability and recovery affordances, especially for non-recoverable context.
### 5. Persistence Parity
Loading chat history should preserve envelope quality (or deterministically reconstruct it), so behavior after load matches in-session behavior.
---
## Current Architecture (Verified)
### Layer Definitions
| Layer | Current Source | Update Trigger | Stability |
| --------------- | ------------------------------------------------------------ | ------------------------------- | --------- |
| **L1_SYSTEM** | `ChatManager.getSystemPromptForMessage()` | settings/memory/project changes | High |
| **L2_PREVIOUS** | Auto-promoted previous user-turn L3 segments | per user turn | Medium |
| **L3_TURN** | Current-turn context artifacts (notes/URLs/tags/folders/etc) | every user turn | Low |
| **L4_STRIP** | Deferred in envelope, injected from LangChain memory | every turn | Low |
| **L5_USER** | processed user query (templated user text) | every user turn | Lowest |
### End-to-End Flow
1. `ChatManager.sendMessage()` creates a user message and resolves L1 system prompt.
2. `ContextManager.processMessageContext()`:
- builds L2 from previous user messages' stored envelopes,
- processes current-turn context artifacts,
- optionally compacts large context,
- builds `PromptContextEnvelope` via `PromptContextEngine`.
3. `MessageRepository.updateProcessedText()` stores both legacy `processedText` and `contextEnvelope`.
4. Chain runners require `contextEnvelope`, convert with `LayerToMessagesConverter`, inject L4 from memory, then append tool context into user-side payload.
### L2/L3 Smart Referencing
- L2 is now deduplicated by segment ID with last-write-wins content updates and stable first-seen ordering.
- L3 segments whose IDs already exist in L2 are rendered as references; new IDs include full content.
- Segment parsing is centralized in `parseContextIntoSegments()` using `contextBlockRegistry` tags.
### Tool Placement Model
- System message contains only L1 + L2.
- Tool outputs remain turn-scoped and are prepended to user-side content (`CiC` ordering).
- This keeps cacheable prefix isolated from tool variability.
### Persistence Behavior
- Chat markdown persists message text plus context references (`[Context: ...]`), not full envelopes.
- On load, messages are restored without `contextEnvelope`.
- Regeneration now has lazy reprocessing: if envelope is missing, `ChatManager.regenerateMessage()` reprocesses the target user message before running the chain.
- Continuing chat after load still does not automatically reconstruct historical envelopes for prior turns.
### Compaction Stack
- **Turn-time compaction** (`ContextCompactor`): map-reduce summarization when total context exceeds threshold.
- **L2 carry-forward compaction** (`compactSegmentForL2` + `L2ContextCompactor`): deterministic structure+preview compression for promoted previous context.
### L4 Memory Behavior
- L4 (chat history) is injected by chain runners from LangChain `BufferWindowMemory`.
- **Only L5 text (bare user message) is saved to memory** — context artifacts are NOT included. `BaseChainRunner.handleResponse()` extracts `l5Text` from the envelope, falling back to `originalMessage` or `message`.
- This prevents duplication: context artifacts already live in L2/L3 via the envelope; baking them into L4 would cause triple-inclusion and waste tokens.
- Assistant responses are compacted at save time by `ChatHistoryCompactor` (strips tool result XML) before storage. Agent-mode responses save only the final answer, not the full reasoning/tool-call chain.
---
## Example Chat Walkthrough
This shows the concrete layer contents across a 3-turn conversation. The user attaches `project-spec.md` in Turn 1, adds `api-docs.md` in Turn 2, then drops `api-docs.md` in Turn 3.
### Turn 1: User adds `project-spec.md`
```
L1 (System):
[system prompt + user memory + project instructions]
L2 (Previous Context Library):
(empty — first turn, no prior context)
L3 (Current Turn Context):
<note_context>
<title>project-spec</title>
<path>project-spec.md</path>
<content>... full note content ...</content>
</note_context>
→ Segment ID: "project-spec.md" (NEW — full content included)
L4 (Chat History):
(empty — first turn)
L5 (User Message):
"Summarize this"
```
After Turn 1, `BaseChainRunner.handleResponse()` saves to memory:
- Input: `"Summarize this"` (displayText only — no context XML)
- Output: `"Here is a summary of the project spec..."`
### Turn 2: User keeps `project-spec.md`, adds `api-docs.md`
```
L1 (System):
[system prompt — stable ✅, cache-friendly]
L2 (Previous Context Library):
<prior_context source="project-spec.md" type="note">
Structure: project-spec (project-spec.md) | Preview: ...first 200 chars...
</prior_context>
→ Segment ID: "project-spec.md" (promoted from Turn 1 L3, compacted for L2)
L3 (Current Turn Context):
Context attached to this message:
- project-spec.md
Find them in the Context Library in the system prompt above.
<note_context>
<title>api-docs</title>
<path>docs/api-docs.md</path>
<content>... full note content ...</content>
</note_context>
→ "project-spec.md" rendered as REFERENCE (already in L2)
→ "docs/api-docs.md" is NEW — full content included
L4 (Chat History):
Human: "Summarize this"
AI: "Here is a summary of the project spec..."
→ Only displayText — no context XML in L4
L5 (User Message):
"What endpoints does the API support?"
```
### Turn 3: User keeps `project-spec.md` only (drops `api-docs.md`)
```
L1 (System):
[system prompt — stable ✅]
L2 (Previous Context Library):
<prior_context source="project-spec.md" type="note">
Structure: project-spec (project-spec.md) | Preview: ...first 200 chars...
</prior_context>
<prior_context source="docs/api-docs.md" type="note">
Structure: api-docs (docs/api-docs.md) | Preview: ...first 200 chars...
</prior_context>
→ Both deduplicated by segment ID. "project-spec.md" retains its
first-seen position; "docs/api-docs.md" added after.
→ L2 is CUMULATIVE and STABLE — cache hit for the prefix ✅
L3 (Current Turn Context):
Context attached to this message:
- project-spec.md
Find them in the Context Library in the system prompt above.
→ "project-spec.md" is a REFERENCE (in L2)
→ "docs/api-docs.md" is NOT referenced (user didn't attach it this turn)
but it remains in L2 for cache stability and potential follow-up use
L4 (Chat History):
Human: "Summarize this"
AI: "Here is a summary of the project spec..."
Human: "What endpoints does the API support?"
AI: "The API supports the following endpoints..."
→ Clean displayText only — no bloat
L5 (User Message):
"Explain the auth flow from the spec"
```
### Key Behaviors Demonstrated
| Behavior | Where | Example |
| ------------------------------- | ----------- | ----------------------------------------------------------------- |
| **Per-artifact segment IDs** | L3 parsing | `"project-spec.md"`, `"docs/api-docs.md"` — not generic `"notes"` |
| **L2 dedup (last-write-wins)** | L2 build | Same ID across turns → content updated, position preserved |
| **Smart referencing** | L3 render | Items in L2 become `- project-spec.md` references |
| **L2 cumulative growth** | L2 library | `api-docs.md` stays in L2 even when dropped from L3 |
| **L2 carry-forward compaction** | L2 content | Full `<note_context>``<prior_context>` with structure+preview |
| **L4 displayText only** | Memory save | `"Summarize this"` — no `<note_context>` XML |
| **Prefix cache stability** | L1+L2 | L1 stable across turns; L2 grows monotonically, doesn't shrink |
---
## Chain Runner Envelope Usage
All four chain runners use the context envelope for LLM message construction. Each delegates final response handling to `BaseChainRunner.handleResponse()`, which saves only L5 text (expanded user query, no context XML) to L4 memory.
### Per-Runner Behavior
| Runner | Envelope Construction | Tool Results | User Message Source |
| ------------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------- |
| **LLMChainRunner** | `LayerToMessagesConverter.convert()` → system (L1+L2), user (L3 refs + L5) | None | Envelope only |
| **CopilotPlusChainRunner** | Same converter, then `ensureUserQueryLabel` adds `[User query]:` separator | Prepended to user message in CiC order | L5 text via envelope |
| **AutonomousAgentChainRunner** | Same converter for initial messages; ReAct loop appends AI + ToolMessages iteratively | Native tool calling — each result is a separate `ToolMessage` | L5 text via envelope |
| **VaultQAChainRunner** | Same converter | Retrieval results via hybrid/lexical retriever | Envelope only |
### CopilotPlus: Single-Shot Tool Flow
1. Planning phase analyzes L5 text to determine which `@commands` to execute.
2. Tool results (localSearch, web fetch, etc.) are formatted and prepended to the user message using CiC ordering: `[tool results] → [L3 references + L5 with User query label]`.
3. Single LLM call with the complete message array: `[system (L1+L2)] → [L4 history] → [user (tools + L3 + L5)]`.
### Autonomous Agent: ReAct Loop Flow
1. Initial message array built identically to CopilotPlus: `[system (L1+L2+tool guidelines)] → [L4 history] → [user (L3 refs + L5)]`.
2. Model responds with native tool calls (e.g., `localSearch`, `readFile`).
3. Each tool result becomes a `ToolMessage` appended to the growing messages array.
4. `localSearch` results get CiC ordering: the user's question (from L5 `originalUserPrompt`) is appended after the search payload via `ensureCiCOrderingWithQuestion`.
5. Loop repeats until model responds without tool calls (final answer).
### Token Efficiency Audit
**Verified efficient (no action needed):**
- L1+L2 prefix is stable and cacheable across turns — tool results never enter the system message.
- L3 uses smart references for artifacts already in L2 — no content duplication in the user message.
- L4 contains only displayText (via L5 extraction in `handleResponse`) — no context XML leakage.
- Chain runners extract L5 text from the envelope for `cleanedUserMessage` and `originalUserPrompt`, never using `processedText` (which contains L2+L3+L5 concatenated).
**Known inefficiencies (accepted tradeoffs):**
| Issue | Severity | Tokens Wasted | Rationale |
| ------------------------------------------------------------------------- | -------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CiC user-question repeated per `localSearch` tool call in agent loop | LOW | ~50 tokens x N searches | Intentional — each `ToolMessage` is independent; the model needs the question for grounding across ReAct iterations |
| L2 content may overlap with `localSearch` results | MEDIUM | Variable (L2 has compacted preview, search has relevant chunks) | Structural limitation — search doesn't know about L2. Overlap is partial since L2 is compacted to structure+preview while search returns precision chunks |
| Legacy `processedText` stores L2+L3+L5 concatenation in MessageRepository | LOW | 0 (not sent to LLM) | Storage-only waste. Field is unused by envelope-based chain runners but persisted for backward compatibility |
---
## Strengths
1. Envelope-first prompt construction is now consistent across all active chain runners.
2. L2 deduplication by artifact ID prevents linear repeated growth for repeated attachments.
3. Segment parsing is centralized and registry-driven, avoiding ad-hoc per-chain parsing logic.
4. Uniform tool placement removes previous cache-boundary ambiguity.
5. Memory-side assistant compaction reduces L4 bloat from tool payloads.
6. Regeneration path is now resilient to missing envelopes on loaded history.
---
## Known Gaps
### P0: No Token Budget Enforcement on Full Payload
- **No compaction mechanism checks the total assembled payload** (L1+L2+L3+L4+L5) against any budget. Each compactor guards only its own subset.
- **L1 (project context) is never budgeted or compacted**: In Projects mode, all project files are concatenated verbatim into L1 with no size limit. This is often the largest single layer.
- **Compaction threshold is blind to L1**: `ContextManager` checks L2+L3 against `autoCompactThreshold` (or hardcoded `PROJECT_COMPACT_THRESHOLD = 1M`), but L1 size is never subtracted. The threshold acts as if L2+L3 is the entire payload.
- **L4 (chat history) has no remaining-budget awareness**: `loadAndAddChatHistory()` loads all history messages regardless of how much budget L1+L2+L3+L5 have already consumed.
- **`contextTurns` is a crude count-based proxy**: `BufferWindowMemory.k = contextTurns * 2` limits by message count, not token size, providing no actual overflow protection.
- See [TOKEN_BUDGET_ENFORCEMENT.md](./TOKEN_BUDGET_ENFORCEMENT.md) for detailed analysis and fix plan.
### P0: Persistence Parity Is Still Incomplete
- Loaded chats do not rehydrate historical envelopes.
- Result: follow-up turns after load do not benefit from historical L2 library unless messages are individually reprocessed.
### P0: Non-Deterministic Fallback Segment IDs
- `appendParsedSegments()` uses `unparsed-${Date.now()}` when parsing fails.
- This breaks deterministic envelope identity and degrades cache behavior in edge cases.
### P1: Parser Still Depends on Regex Over Rendered XML
- `parseContextIntoSegments()` is significantly better than earlier local regex logic, but it still parses serialized XML strings rather than typed artifacts.
- Malformed/nested edge cases can still create parse misses and fallback behavior.
### P1: Compaction Semantics Need Stronger Invariants
- Two compaction modes exist (LLM summarization vs deterministic L2 preview compaction), but explicit invariants on what must remain verbatim are not enforced centrally.
- Non-recoverable context (e.g., selected text) needs stricter protection policies under heavy compaction.
### P1: L2 Mutation Tradeoff Not Formalized
- Current policy is ID-dedup + content overwrite.
- This is token-efficient, but mutable artifacts can invalidate long cached prefixes when content changes.
- The system needs an explicit "freshness vs cache stability" policy.
### P2: Envelope Metadata Is Underused
- `conversationId` is currently `null`.
- Missing stable conversation-level identity weakens observability and future caching strategies.
### P2: Documentation Drift Risk
- Prior docs contained stale statements (e.g., fallback-to-processedText behavior, on-load envelope reconstruction).
- This doc now reflects current code; future changes should keep this aligned.
---
## Improvement Roadmap
### Phase 1: Correctness and Determinism
1. Replace timestamp fallback IDs with deterministic IDs:
- `unparsed:${sha256(content)}` (plus optional short prefix by source type).
2. Add envelope invariants checker (debug + tests):
- no duplicate segment IDs inside a layer,
- no L3 full-content block when ID exists in L2 unless explicitly marked override,
- stable layer ordering and hash consistency.
3. Add robust parse-failure telemetry:
- count parse misses,
- log failing tag/source metadata,
- capture hash-only samples in debug mode.
### Phase 2: Persistence Parity
1. Add lazy historical envelope reconstruction on first post-load send:
- reprocess only prior user messages that have context references and missing envelopes,
- skip URL/web-tab refetch by policy where needed.
2. Optional long-term path:
- persist compact envelope metadata (or typed artifact snapshots) alongside markdown history for deterministic restoration.
### Phase 3: Compaction Safety and Policy
1. Define explicit compaction classes:
- recoverable artifacts: can be summarized with re-fetch instructions,
- non-recoverable artifacts: preserve verbatim or bounded extractive compaction only.
2. Add post-compaction validation:
- each compacted artifact must retain deterministic source identity and recoverability hints.
3. Make compaction strategy configurable by chain type and context source type.
### Phase 4: Cache Optimization
1. Split L1 into stable and mutable subsections (for example:
- static system contract,
- user memory and project overlays) to reduce unnecessary prefix invalidation.
2. Introduce provider-aware cache hooks (opt-in):
- Anthropic `cache_control`,
- Gemini explicit cache primitives,
- keep model-agnostic baseline unchanged.
3. Add per-turn prefix hash diff reporting:
- `L1 hash`, `L2 hash`, combined prefix hash,
- classify why prefix changed (settings, context attach, file change, memory update).
### Phase 5: Typed Artifact Pipeline (Strategic)
Move from "render XML then parse XML" to a typed artifact graph:
- `ContextProcessor` emits typed artifacts directly (`artifactKey`, `sourceType`, `recoverable`, `payload`, `contentHash`).
- Envelope stores typed segments as canonical source-of-truth.
- XML remains a rendering format, not parsing substrate.
This is the highest-leverage change for long-term reproducibility and parser robustness.
### Phase 6: Context Envelope Integration Test Suite
Build a comprehensive test suite that validates multi-turn envelope behavior without requiring manual UI testing:
1. **Multi-turn envelope simulation tests**:
- Simulate 3+ turn conversations with various artifact combinations (notes, URLs, YouTube, PDFs, selected text).
- Assert correct L2 promotion, dedup, smart referencing, and compaction at each turn.
- Validate that L4 memory contains only displayText (no context XML leakage).
2. **Layer composition snapshot tests**:
- For canonical conversation trajectories, snapshot the full `[L1, L2, L3, L4, L5]` payload sent to the LLM.
- Detect unintended regressions in layer ordering, dedup behavior, or content placement.
3. **Round-trip persistence tests**:
- Save a conversation to markdown, reload it, send a follow-up turn.
- Assert that lazy reprocessing reconstructs envelopes and L2 correctly.
4. **Edge-case regression tests**:
- Same artifact attached across 5+ turns (dedup stability).
- Artifact added, removed, re-added (L2 cumulative behavior).
- Multiple `selected_text` blocks in same turn (unique ID generation).
- Malformed XML blocks (graceful fallback, no silent data loss).
- Very large context triggering compaction (invariants preserved).
5. **Property-based tests** (optional, aspirational):
- Generate random artifact sequences and assert envelope invariants hold:
no duplicate segment IDs within a layer, L3 references only exist if ID is in L2,
L4 never contains XML block tags.
This suite replaces the need for manual multi-turn chat testing in the UI and provides a safety net for all future envelope changes.
---
## Testing and Observability
### Core Tests to Add/Strengthen
1. Post-load follow-up turn should rebuild/rehydrate envelope behavior deterministically.
2. Deterministic fallback ID behavior (no wall-clock dependence).
3. Property tests for parser with malformed/nested blocks.
4. Compaction invariants:
- non-recoverable blocks never become unrecoverable summaries without explicit guardrails.
5. Prefix-hash stability tests across common conversation trajectories.
### Runtime Metrics (Debug Mode)
- Envelope build time by phase (L2 build, context processing, compaction, render).
- Segment counts per layer and dedup ratio.
- Prefix hash change reason classification.
- Parse-failure count and compacted-context proportion.
---
## References
### Primary Implementation Files
- `src/core/ChatManager.ts`
- `src/core/ContextManager.ts`
- `src/context/PromptContextTypes.ts`
- `src/context/PromptContextEngine.ts`
- `src/context/parseContextSegments.ts`
- `src/context/LayerToMessagesConverter.ts`
- `src/core/MessageRepository.ts`
- `src/core/ChatPersistenceManager.ts`
- `src/LLMProviders/chainRunner/LLMChainRunner.ts`
- `src/LLMProviders/chainRunner/VaultQAChainRunner.ts`
- `src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts`
- `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts`
### Related Docs
- `designdocs/MESSAGE_ARCHITECTURE.md`
- `designdocs/TOOLS.md`
- `designdocs/NATIVE_TOOL_CALLING_MIGRATION.md`
- `designdocs/todo/TECHDEBT.md`
- `TODO.md`

View file

@ -2,6 +2,8 @@
This document describes the new message management and context processing architecture that replaced the legacy SharedState system. The new design follows clean architecture principles with a single source of truth, computed views, and complete project isolation.
**Note**: For detailed information about the layered context system (L1-L5 layers, L2 auto-promotion, cache optimization), see [CONTEXT_ENGINEERING.md](./CONTEXT_ENGINEERING.md).
## Architecture Principles
### Single Source of Truth
@ -51,7 +53,13 @@ LLM Processing ← Chain Memory ← getLLMMessages() ← MessageRepository
```typescript
// Add new message
addMessage(displayText: string, processedText: string, sender: string, context?: MessageContext): string
addMessage(
displayText: string,
processedText: string,
sender: string,
context?: MessageContext,
content?: MessageContent[]
): string
// Get computed views
getDisplayMessages(): ChatMessage[] // For UI rendering
@ -59,13 +67,19 @@ getLLMMessages(): ChatMessage[] // For AI processing
// Edit operations
editMessage(id: string, newDisplayText: string): boolean
updateProcessedText(id: string, processedText: string): boolean
updateProcessedText(
id: string,
processedText: string,
contextEnvelope?: PromptContextEnvelope
): boolean
// Bulk operations
truncateAfterMessageId(messageId: string): void
loadMessages(messages: ChatMessage[]): void
```
> **Storing envelopes**: When `ChatManager` calls `addMessage` with a full `NewChatMessage`, it includes the `contextEnvelope` property so the repository keeps both the legacy `processedText` and the canonical layered representation. The string-based overload remains for low-level utilities and test fixtures.
### 2. ChatManager (`src/core/ChatManager.ts`)
**Purpose**: Central business logic coordinator
@ -79,6 +93,13 @@ loadMessages(messages: ChatMessage[]): void
- **Project Isolation**: Maintains separate MessageRepository per project
- **Persistence**: Integrates with ChatPersistenceManager for saving/loading
**Envelope Lifecycle**:
1. User sends a message → `ContextManager.processMessageContext()` returns both `processedContent` **and** a `PromptContextEnvelope`.
2. `ChatManager` stores the envelope with `MessageRepository.updateProcessedText(...)`.
3. When any chain runner executes, it reads `userMessage.contextEnvelope` and feeds it to `LayerToMessagesConverter.convert()` to materialize the L1-L5 prompt structure.
4. Regeneration or edits call `reprocessMessageContext`, ensuring a fresh envelope replaces the stale one.
**Key Operations**:
```typescript
@ -155,13 +176,36 @@ private notifyListeners(): void
### 4. ContextManager (`src/core/ContextManager.ts`)
**Purpose**: Handles context processing and reprocessing
**Purpose**: Handles context processing and reprocessing with layered context architecture
**Key Features**:
- Processes message context (notes, URLs, selected text)
- Reprocesses context when messages are edited
- Ensures fresh context for LLM processing
- **Layered Context System**: Builds structured L1-L5 context layers (see CONTEXT_ENGINEERING.md)
- **L2 Auto-Promotion**: Automatically promotes previous turn's context to L2 for cache stability
- **Deduplication**: Ensures context appears only once (L3 takes priority over L2)
- **Context Processing**: Handles notes, URLs, selected text, tags, and folders
- **Reprocessing**: Regenerates fresh context when messages are edited
- **Envelope Building**: Creates `PromptContextEnvelope` with structured layers and hashes
- **Chain-Aware Processing**: Applies chain-specific rules (e.g., Copilot Plus URL processing, active-note handling for vision models)
**Core Methods**:
```typescript
// Process context for new message (includes L2 building from history)
async processMessageContext(
message: ChatMessage,
fileParserManager: FileParserManager,
vault: Vault,
chainType: ChainType,
includeActiveNote: boolean,
activeNote: TFile | null,
messageRepo: MessageRepository,
systemPrompt?: string
): Promise<ContextProcessingResult>
// Reprocess context for edited message
async reprocessMessageContext(messageId: string, ...): Promise<void>
```
### 5. ChatPersistenceManager (`src/core/ChatPersistenceManager.ts`)
@ -341,6 +385,13 @@ When a user types "Summarize this note" and attaches "meeting-notes.md":
All context is wrapped in semantic XML tags for clear structure:
> Layered Prompting: During Phase 3 the raw XML is still captured in `processedText` for backward compatibility, but the canonical representation sent to the LLM comes from the envelope layers:
>
> - **L1_SYSTEM / L2_PREVIOUS**: Stable prefixes rendered from accumulated context
> - **L3_TURN**: Turn-specific smart references that either link back to L2 or embed full content
> - **L4_STRIP**: Chat history managed by memory
> - **L5_USER**: The users raw message (plus composer directives when present)
#### Note Context
```xml
@ -414,7 +465,7 @@ MessageRepository.addMessage() // Store with basic content
ContextManager.processMessageContext() // Add context
MessageRepository.updateProcessedText() // Update with context
MessageRepository.updateProcessedText() // Update with processed text + context envelope
ChatManager.updateChainMemory() // Sync to LLM
@ -676,6 +727,11 @@ console.log({
## Related Files
### Documentation
- `designdocs/CONTEXT_ENGINEERING.md` - Layered context system design and L2 auto-promotion details
- `designdocs/MESSAGE_ARCHITECTURE.md` - This document (message management architecture)
### Core Implementation
- `src/core/MessageRepository.ts` - Message storage

View file

@ -0,0 +1,783 @@
# Obsidian CLI Integration Design (MVP)
**Date:** 2026-02-11
**Status:** Draft — Experimental, Desktop-Only
**Scope:** Copilot plugin tooling (`AutonomousAgent` + `Copilot Plus` tool execution path)
## 1. Problem Statement
Obsidian now ships an official CLI (early access). Copilot already has a mature tool system, but it currently duplicates vault operations through plugin APIs and internal tool implementations. We need a low-risk path to leverage CLI capabilities without rewriting the agent loop.
## 2. Goals
1. Add Obsidian CLI capabilities with minimal changes to existing architecture.
2. Reuse current `ToolRegistry` + LangChain native tool calling flow.
3. Keep the first release desktop-only, safe-by-default, and observable.
4. Ship capabilities in explicit versioned tiers (`v0` -> `v1` -> `v2`) to keep routing and validation manageable.
## 3. Non-Goals (MVP)
1. No full one-to-one wrapper for every CLI command in the first iteration.
2. No mobile support (CLI is desktop-oriented).
3. No prompt/system-prompt rewrites beyond normal tool metadata guidance.
4. No dependency on interactive TUI mode in agent flows.
## 3a. Platform Policy
These tools are **desktop-only** and **experimental**.
- On mobile platforms, CLI tools are **not registered** in the `ToolRegistry` and are completely invisible to the user — they do not appear in tool settings, tool lists, agent reasoning, or any UI surface.
- Registration is gated by `Platform.isDesktopApp` in `initializeBuiltinTools()`. The runtime guard in `ObsidianCliClient.runObsidianCliCommand()` provides defense-in-depth but is not the primary gating mechanism.
- The Obsidian CLI requires `child_process.execFile`, which is only available in the desktop Electron renderer.
## 3b. Design Rationale — CLI Shell-Out vs Internal API
The CLI shell-out approach was chosen because:
1. **Breadth without cost**: The CLI exposes a large and growing command surface. Reimplementing each command via internal Obsidian APIs (`app.vault`, `app.metadataCache`, etc.) would require significant per-command development and maintenance effort.
2. **Forward compatibility**: New CLI commands become available to the tool system without code changes — only the allowlist needs updating.
3. **Safety**: `execFile` (not shell) with strict argument serialization prevents injection. A command allowlist and mutation gating limit blast radius.
4. **Incremental adoption**: The versioned tier system (v0 → v1 → v2) allows cautious rollout, starting with read-only commands.
Internal API tools remain the right choice for operations that need deep integration (e.g., frontmatter processing via `app.fileManager.processFrontMatter()`). The CLI approach is complementary, not a replacement.
## 4. Current Architecture Fit
Relevant integration points:
- `src/tools/ToolRegistry.ts` for tool registration and metadata.
- `src/tools/builtinTools.ts` for built-in tool definitions and initialization.
- `src/LLMProviders/chainRunner/utils/toolExecution.ts` for execution control and user-facing tool status behavior.
- `src/settings/model.ts` and `src/constants.ts` for defaults and persisted settings.
- `src/settings/v2/components/ToolSettingsSection.tsx` for user tool toggles.
This means we can ship CLI support as one additional built-in tool (or a small tool set) without changing chat/message architecture.
## 5. Tool Organization: Category-Based Grouping
Instead of one tool per CLI command (~100 commands = too many tools) or one generic umbrella tool (too vague for LLM routing), commands are grouped into **category-based tools**. Each tool accepts a `command` parameter scoped to its category.
### Design Rationale
- **Clear semantic signals for the LLM**: User asks about daily notes → `obsidianDailyNote` tool. No ambiguity in tool selection.
- **Scales well**: New CLI commands slot into existing category tools without adding new tool registrations.
- **Manageable tool count**: ~10 tools total vs ~25+ individual tools or 1 opaque gateway.
- **Per-category allowlists**: Each tool validates its `command` parameter against a scoped allowlist, limiting blast radius.
### v0 (Current — 2 commands, 2 tools)
| Tool | Commands | Notes |
| -------------------- | ------------- | -------------------------------------------- |
| `obsidianDailyRead` | `daily:read` | Read-only. Dedicated tool for v0 simplicity. |
| `obsidianRandomRead` | `random:read` | Read-only. Dedicated tool for v0 simplicity. |
### v1 (Current — 13 commands across 7 tools)
All v1 tools are **read-only or direct-execution** (no confirmation UX required).
| Tool | Commands | Notes |
| ---------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| **obsidianDailyNote** | `daily:read`, `daily:append`, `daily:prepend`, `daily:path` | Append/prepend execute directly (see Write Operations Policy). Subsumes v0 `obsidianDailyRead`. |
| **obsidianProperties** | `properties`, `property:read` | Read-only. Write commands (`property:set`, `property:remove`) deferred to v2. |
| **obsidianTasks** | `tasks` | Read-only (task listing). Write command (`task` toggle/status) deferred to v2. |
| **obsidianRandomRead** | `random:read` | Read-only. Standalone tool (single command). Continues from v0. |
| **obsidianLinks** | `backlinks`, `links`, `orphans`, `unresolved` | All read-only |
| **obsidianTemplates** | `templates`, `template:read` | Read-only. `template:insert` deferred (requires active file context). Moved from v2. |
| **obsidianBases** | `bases`, `base:views`, `base:query`, `base:create` | Read + create. `base:create` executes directly (see Write Operations Policy). |
### v2 (Future — ~9 commands: 3 mutations on existing tools + 2 new tools)
v2 introduces **confirmation-required mutations** on existing v1 tools and adds new tool categories.
| Tool | Commands | Notes |
| --------------------------------------- | --------------------------------- | ----------------------------------------------------------------------- |
| **obsidianProperties** _(v1 extension)_ | `property:set`, `property:remove` | Light confirmation in chat before executing. Extends v1 read-only tool. |
| **obsidianTasks** _(v1 extension)_ | `task` (toggle/done/todo/status) | Light confirmation in chat before executing. Extends v1 read-only tool. |
| **obsidianBookmarks** | `bookmarks`, `bookmark` | `bookmark` (add) gated by mutation setting |
### Excluded from Tool System
The following CLI commands are **not exposed** to the AI agent:
| Category | Commands | Rationale |
| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Destructive file ops | `delete`, `move`, `rename`, `create --overwrite` | Too dangerous for autonomous agent use |
| Plugin/theme management | `plugin:*`, `theme:*`, `snippet:*` | Not an AI task, security risk |
| Sync & history | `sync:*`, `history:restore`, `diff` | User-managed operations, data loss risk |
| UI/workspace control | `tabs`, `tab:open`, `workspace`, `open`, `daily` (open variant) | UI-only, no data value for LLM |
| System commands | `reload`, `restart`, `version`, `vault`, `vaults` | Not useful for agent workflows |
| Developer tools | `eval`, `dev:*`, `devtools` | Arbitrary code execution risk |
| Niche metadata | `aliases`, `wordcount`, `recents`, `hotkeys`, `commands` | Low AI synergy |
| Search | `search`, `search:context`, `search:open` | Redundant with Copilot's existing keyword + semantic search (`localSearch`) |
| File read | `read` | Redundant with Copilot's existing `readNote` tool (see Tool Disambiguation) |
| Tag listing | `tags` | Redundant with Copilot's existing `getTagList` tool (see Tool Disambiguation) |
| Arbitrary file writes | `append`, `prepend`, `create` | File modifications beyond daily notes should go through the existing Composer tool (`writeToFile`/`replaceInFile`) |
### Write Operations Policy
| Operation | Execution model | Tier | Rationale |
| --------------------------------- | ---------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------- |
| **Daily note append/prepend** | Direct execution, show result in chat response | v1 | User explicitly asked for the action; daily notes are append-only by nature and low-risk |
| **Base create** | Direct execution, show result in chat response | v1 | User explicitly asked to add an item; creates a new note matching Base filters, additive and low-risk |
| **Arbitrary file append/prepend** | Excluded — use Composer tool | — | File modifications beyond daily notes need the Composer diff/preview UX for safety |
| **Property set/remove** | Light confirmation in chat before executing | v2 | Metadata changes are reversible but should be intentional; deferred to validate read-only tools first |
| **Task toggle/status** | Light confirmation in chat before executing | v2 | Status changes are reversible but should be intentional; deferred to validate read-only tools first |
### Tool Disambiguation
CLI tools are **complementary** to existing internal tools, not replacements. Several CLI commands were evaluated and intentionally excluded because Copilot already has superior internal implementations:
| CLI Command | Existing Internal Tool | Why Internal Wins |
| ----------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `read` | `readNote` | In-process (`app.vault.cachedRead`), 200-line chunking, multi-strategy path resolution (wikilink, basename, partial match), linked notes extraction, mtime metadata. CLI `read` spawns a process, returns raw text, and requires exact paths. |
| `tags` | `getTagList` | In-process (`app.metadataCache`), structured JSON with occurrence counts, frontmatter/inline breakdown, progressive size limiting (500KB cap), configurable `maxEntries`. CLI `tags` returns unstructured text with no filtering. |
| `search`, `search:context` | `localSearch` | Hybrid keyword + semantic search with BM25, query expansion, reranking, time range filtering, tag-aware retrieval. CLI search is basic text matching. |
| `append`, `prepend`, `create` | `writeToFile` / `replaceInFile` | Composer diff/preview UX for safety, line-ending normalization, SEARCH/REPLACE blocks, auto-accept setting. Exception: `daily:append`/`daily:prepend` use CLI directly (low-risk, append-only). |
**Prompt instruction guidelines for CLI tools:**
- Each CLI tool's `customPromptInstructions` must include explicit disambiguation guidance directing the LLM to the correct tool.
- Example: "Use `readNote` for reading specific notes by path. Use `obsidianDailyNote` for daily note operations (read, append, prepend). Use `obsidianRandomRead` for picking a random note."
- When an existing internal tool and a CLI tool could both handle a request, the internal tool should be preferred unless the CLI tool provides unique capability (e.g., daily note path resolution, random note selection, backlink traversal).
## 6. Implementation Design
### 6.1 Service Layer: `ObsidianCliClient`
Located at `src/services/obsidianCli/ObsidianCliClient.ts`. Responsible for:
1. CLI availability/version checks.
2. Safe command execution via `execFile` (not shell).
3. Argument serialization to `parameter=value` and boolean flags.
4. Timeout + output-size limits.
5. Structured error mapping for tool responses.
6. Fallback binary resolution (tries `obsidian` → known macOS app paths).
Key guardrails:
- No shell interpolation.
- Per-tool command allowlist.
- Desktop-only runtime guard.
### 6.2 Tool Layer
Each category tool is a LangChain `StructuredTool` with a zod schema. Tools are registered conditionally in `src/tools/builtinTools.ts` via `registerCliTools()`, gated by `Platform.isDesktopApp`.
### 6.3 Settings
Planned settings fields:
1. `obsidianCliAllowMutations: boolean` (default `false`) — gates write commands across all CLI tools.
2. `obsidianCliTimeoutMs: number` (default `15000`) — per-command timeout.
3. `obsidianCliPath: string` (default `"obsidian"`) — custom binary path override.
## 7. Execution + UX Rules
1. If CLI unavailable, return a clear actionable tool error.
2. If running on mobile, return unsupported-platform error.
3. Surface tool result summaries in existing tool banners/reasoning stream.
4. Keep file-modifying behavior conservative until explicit mutation rollout.
## 8. Testing Plan
Unit tests:
1. Argument serialization (`parameter=value`, booleans, multiline escaping handling).
2. Allowlist enforcement and mutation gating.
3. Timeout/error mapping behavior.
4. Desktop/mobile guards.
Integration tests (mocked process):
1. Successful command execution output.
2. Non-zero exit behavior.
3. Large output truncation/handling.
Manual validation:
1. Run read-only commands from agent mode and verify response quality.
2. Verify settings toggles affect tool availability correctly.
## 9. Rollout Plan
### Phase 0: Design + scaffolding (done)
- Design doc, `ObsidianCliClient` service, `obsidianDailyRead`/`obsidianRandomRead` tools.
- Desktop-only gating via `Platform.isDesktopApp` in `registerCliTools()`.
- Tests for arg serialization, fallback binary resolution, tool wrappers.
### Phase 1: v0 release (current)
- Ship `daily:read` and `random:read` as dedicated read-only tools.
- Validate CLI reliability and UX across desktop platforms.
### Phase 2: v1 expansion
- Refactor v0 `obsidianDailyRead` into category-based `obsidianDailyNote` (read, append, prepend, path).
- Keep `obsidianRandomRead` as standalone tool (single command).
- Add `obsidianProperties` (read-only), `obsidianTasks` (read-only), `obsidianLinks` tools.
- All v1 tools are read-only or direct-execution — no confirmation UX needed.
- Add per-tool command allowlists and prompt disambiguation guidance.
### Phase 3: v2 expansion
- Add confirmation-required mutations to existing v1 tools: `property:set`, `property:remove`, `task` toggle/status.
- Implement mutation gating via `obsidianCliAllowMutations` setting and light confirmation UX.
- Add new tool categories: `obsidianTemplates`, `obsidianBases`, `obsidianBookmarks`.
## 10. Risks and Mitigations
1. CLI behavior/version drift.
Mitigation: version checks + graceful fallback.
2. Security risks from command injection.
Mitigation: `execFile`, allowlist, strict param serializer.
3. UX confusion between existing tools and CLI-backed actions.
Mitigation: clear tool naming + incremental command scope.
## 11. Open Questions
### Resolved
1. ~~Should `obsidianCli` be exposed in standard tool settings for all users, or hidden behind a feature flag first?~~
**Resolved**: CLI tools are registered in `ToolRegistry` like any other tool, gated by `Platform.isDesktopApp`. No separate feature flag — they appear in tool settings on desktop, invisible on mobile.
2. ~~Do we want a dedicated category for CLI-backed tools?~~
**Resolved**: No dedicated category. CLI tools use category `"file"` alongside existing file tools. They are distinguished by their `id` prefix (`obsidian*`) and `displayName` suffix `(CLI)`.
3. ~~Should we prefer existing internal tools over CLI for certain operations (for consistency/performance)?~~
**Resolved**: Yes. Internal tools are preferred when they exist. See Tool Disambiguation section — `readNote` over CLI `read`, `getTagList` over CLI `tags`, `localSearch` over CLI `search`, Composer over CLI file writes. CLI tools are only used for capabilities without an internal equivalent.
### Open
1. What minimum CLI version should be required for initial support?
2. How should the agent reliably choose between similar tools when both could handle a request? For example, `daily:append`/`daily:prepend` vs the Composer tool (`writeToFile`/`replaceInFile`) when the user says "add something to my daily note." Current approach is prompt-instruction disambiguation, but this depends on LLM adherence to instructions. Alternatives: remove overlapping commands entirely, or add runtime routing that intercepts and redirects.
3. Can we reliably resolve the Obsidian CLI binary path across platforms? Current approach: try `obsidian` on PATH → env vars (`OBSIDIAN_CLI_BINARY`, `OBSIDIAN_CLI_PATH`) → macOS fallback paths (`/Applications/Obsidian.app/Contents/MacOS/obsidian`). Windows and Linux fallback paths are not yet implemented. If the CLI is not on PATH and no env var is set, the tool fails. Should we add a settings field for manual path override, or auto-detect from known install locations per platform?
---
## Appendix A: V1 CLI Command Reference
All commands are invoked as `obsidian <command> [params...]`. Output is **plain text** (no `format=json`) — LLMs consume text natively without the token overhead of JSON structure.
Global parameter available on all commands: `vault=<name>` (targets a specific vault; omit for default).
### A.1 `obsidianDailyNote` — Daily Note Operations
#### `daily:read`
Read today's daily note content.
```
obsidian daily:read
```
| Parameter | Required | Description |
| --------- | -------- | ---------------------------------------------- |
| _(none)_ | | No parameters. Reads the daily note for today. |
**Output**: Full markdown content of today's daily note. Empty string if no daily note exists.
```
# 2026-03-03
## Tasks
- [ ] Review PR #2181
- [x] Update design doc
## Notes
Meeting with Alice about CLI integration...
```
#### `daily:path`
Get the vault-relative file path of today's daily note.
```
obsidian daily:path
```
| Parameter | Required | Description |
| --------- | -------- | -------------- |
| _(none)_ | | No parameters. |
**Output**: Single line — the vault-relative path.
```
2026-03-03.md
```
This is the only way to discover the daily note path without knowing the user's daily note folder/date format configuration.
#### `daily:append`
Append content to the end of today's daily note. Creates the daily note if it doesn't exist.
```
obsidian daily:append content="- Meeting with Alice at 3pm"
```
| Parameter | Required | Description |
| ---------------- | -------- | ----------------------------------------------- |
| `content=<text>` | Yes | Text to append. |
| `inline` | No | Boolean flag. Append without a leading newline. |
**Output**: Empty on success. The content is added at the end of the file.
**Note**: `open` and `paneType` parameters are accepted by the CLI but are not passed by the tool (UI-only, no value for agent).
#### `daily:prepend`
Prepend content to the beginning of today's daily note (after frontmatter). Creates the daily note if it doesn't exist.
```
obsidian daily:prepend content="## Morning Standup"
```
| Parameter | Required | Description |
| ---------------- | -------- | ------------------------------------------------- |
| `content=<text>` | Yes | Text to prepend. |
| `inline` | No | Boolean flag. Prepend without a trailing newline. |
**Output**: Empty on success.
---
### A.2 `obsidianProperties` — Note Property Access
#### `properties`
List frontmatter properties. Can operate vault-wide or on a specific note.
**Vault-wide** (list all property names used across the vault):
```
obsidian properties
```
```
aliases
author
cssclasses
date
tags
title
```
**For a specific note** (list that note's property key-value pairs):
```
obsidian properties file="Rewrite as tweet"
```
```
copilot-command-context-menu-enabled: false
copilot-command-slash-enabled: false
copilot-command-context-menu-order: 90
copilot-command-model-key: ""
copilot-command-last-used: 0
```
| Parameter | Required | Description |
| ------------- | -------- | --------------------------------------------------------- |
| `file=<name>` | No | Target file by name (without extension). |
| `path=<path>` | No | Target file by vault-relative path. |
| `name=<name>` | No | Get count for a specific property name (vault-wide mode). |
| `counts` | No | Include occurrence counts (vault-wide mode). |
| `sort=count` | No | Sort by count instead of name (vault-wide mode). |
| `total` | No | Return only the property count. |
**Output (vault-wide)**: One property name per line, alphabetically sorted by default. With `counts`, format is `name: count`. With `total`, a single number.
**Output (per-file)**: `key: value` pairs, one per line (YAML-like).
#### `property:read`
Read a single property value from a specific note.
```
obsidian property:read name="tags" file="My Note"
```
| Parameter | Required | Description |
| ------------- | -------- | ----------------------------------- |
| `name=<name>` | Yes | Property name to read. |
| `file=<name>` | No | Target file by name. |
| `path=<path>` | No | Target file by vault-relative path. |
**Output**: The raw property value. For arrays, comma-separated. For strings, the plain value.
```
90
```
---
### A.3 `obsidianTasks` — Task Listing
#### `tasks`
List tasks across the vault with filtering options.
```
obsidian tasks todo
obsidian tasks file="Project Plan" verbose
obsidian tasks daily
```
| Parameter | Required | Description |
| ----------------- | -------- | ---------------------------------------------------------------- |
| `file=<name>` | No | Filter by file name. |
| `path=<path>` | No | Filter by file path. |
| `todo` | No | Show only incomplete tasks. |
| `done` | No | Show only completed tasks. |
| `status="<char>"` | No | Filter by status character (e.g., `status="/"` for in-progress). |
| `daily` | No | Show tasks from today's daily note. |
| `verbose` | No | Group tasks by file with line numbers. |
| `total` | No | Return only the task count. |
**Output (default text)**: One task per line, markdown checkbox format.
```
- [ ] Review PR #2181
- [ ] Update design doc
- [x] Write CLI client tests
```
**Output (verbose)**: Tasks grouped under file headings with line numbers.
```
Projects/launch-plan.md
L12: - [ ] Review PR #2181
L15: - [x] Write CLI client tests
Daily/2026-03-03.md
L8: - [ ] Update design doc
```
**Output (total)**: Single number.
```
3
```
**Empty result**: `No tasks found.`
---
### A.4 `obsidianRandomRead` — Random Note
#### `random:read`
Read a randomly selected markdown note from the vault.
```
obsidian random:read
obsidian random:read folder="Ideas"
```
| Parameter | Required | Description |
| --------------- | -------- | ------------------------------------- |
| `folder=<path>` | No | Limit selection to a specific folder. |
**Output**: Full markdown content of the randomly selected note. A different note is returned each invocation.
**Empty result**: `No markdown files found.` (when folder is empty or doesn't exist).
---
### A.5 `obsidianLinks` — Link Graph Queries
#### `backlinks`
List notes that link TO a given file (incoming links).
```
obsidian backlinks file="My Note"
obsidian backlinks path="Projects/plan.md" counts
```
| Parameter | Required | Description |
| ------------- | -------- | ------------------------------------ |
| `file=<name>` | No | Target file by name. |
| `path=<path>` | No | Target file by vault-relative path. |
| `counts` | No | Include link counts per source file. |
| `total` | No | Return only the backlink count. |
**Output (default TSV)**: One source file per line.
```
Projects/roadmap.md
Daily/2026-03-01.md
```
**Output (counts)**: Source file with link count.
```
Projects/roadmap.md 3
Daily/2026-03-01.md 1
```
**Output (total)**: Single number.
**Empty result**: `No backlinks found.`
#### `links`
List outgoing links FROM a given file.
```
obsidian links file="My Note"
obsidian links path="Projects/plan.md" total
```
| Parameter | Required | Description |
| ------------- | -------- | ----------------------------------- |
| `file=<name>` | No | Source file by name. |
| `path=<path>` | No | Source file by vault-relative path. |
| `total` | No | Return only the link count. |
**Output**: One link target per line.
```
Projects/roadmap.md
Ideas/brainstorm.md
```
**Empty result**: `No links found.`
#### `orphans`
List files with no incoming links (not linked from any other note).
```
obsidian orphans
obsidian orphans total
```
| Parameter | Required | Description |
| --------- | -------- | ------------------------------------------------ |
| `total` | No | Return only the orphan count. |
| `all` | No | Include non-markdown files (images, PDFs, etc.). |
**Output**: One file path per line.
```
2026-03-03.md
BOT/DailyAIDigest/2026-02-26-Daily-AI-Digest.md
copilot/copilot-conversations/hello@20260302_145233.md
DemoCanvas.canvas
```
**Output (total)**: Single number (e.g., `84`).
#### `unresolved`
List wikilinks that don't resolve to any existing file in the vault.
```
obsidian unresolved
obsidian unresolved counts verbose
obsidian unresolved total
```
| Parameter | Required | Description |
| --------- | -------- | ---------------------------------------------------- |
| `counts` | No | Include how many times each unresolved link appears. |
| `verbose` | No | Include source file for each unresolved link. |
| `total` | No | Return only the unresolved link count. |
**Output (default TSV)**: One unresolved link target per line.
```
Nonexistent Note
Old Project Reference
meeting-notes-2025
```
**Output (counts)**: Link target with occurrence count.
```
Nonexistent Note 5
Old Project Reference 2
```
**Output (verbose)**: Link target with source files.
```
Nonexistent Note Projects/roadmap.md
Nonexistent Note Daily/2026-03-01.md
Old Project Reference Archive/cleanup.md
```
**Output (total)**: Single number (e.g., `771`).
---
### A.6 `obsidianTemplates` — Template Listing and Reading
#### `templates`
List all available template names in the configured templates folder.
```
obsidian templates
```
| Parameter | Required | Description |
| --------- | -------- | ---------------------------------------- |
| _(none)_ | | No parameters. Lists all template names. |
**Output**: One template name per line.
```
Daily Note
Meeting Notes
Project Plan
Weekly Review
```
---
#### `template:read`
Read a template's content with variable placeholders resolved.
```
obsidian template:read name="Daily Note"
```
| Parameter | Required | Description |
| ------------- | -------- | ------------------------------------------- |
| `name=<name>` | Yes | Template name (as returned by `templates`). |
**Output**: Full markdown content of the template.
```
# {{date}}
## Tasks
- [ ]
## Notes
```
---
### A.7 `obsidianBases` — Base Database Queries
#### `bases`
List all Base (database) files in the vault.
```
obsidian bases
```
| Parameter | Required | Description |
| --------- | -------- | ------------------------------------ |
| `total` | No | Return only the count of Base files. |
**Output**: One Base file per line.
```
Contacts.base
Projects.base
Tasks.base
```
**Output (total)**: Single number.
#### `base:views`
List views defined in a Base file.
```
obsidian base:views file="Projects"
obsidian base:views path="Databases/Projects.base"
```
| Parameter | Required | Description |
| ------------- | -------- | --------------------------------------------- |
| `file=<name>` | No\* | Target Base file by name (without extension). |
| `path=<path>` | No\* | Target Base file by vault-relative path. |
\* One of `file` or `path` is required.
**Output**: One view name per line.
```
All Items
By Status
Kanban
```
#### `base:create`
Create a new item (row) in a Base. The created item is a new markdown note that matches the Base's filter criteria.
```
obsidian base:create file="Library" name="Dune Messiah" content="A book by Frank Herbert"
obsidian base:create path="Databases/Projects.base" view="Active" name="New Feature"
```
| Parameter | Required | Description |
| ---------------- | -------- | ------------------------------------------------------------- |
| `file=<name>` | No\* | Target Base file by name (without extension). |
| `path=<path>` | No\* | Target Base file by vault-relative path. |
| `view=<name>` | No | View to add the item to. Omit for default view. |
| `name=<name>` | No | File name for the created note. Omit for auto-generated name. |
| `content=<text>` | No | Initial markdown content for the note. |
\* One of `file` or `path` is required.
**Output**: Confirmation message with the path of the created note.
**Note**: `open` and `newtab` parameters are accepted by the CLI but not passed by the tool (UI-only, no value for agent).
---
#### `base:query`
Query data from a Base view.
```
obsidian base:query file="Projects" view="All Items"
obsidian base:query path="Databases/Projects.base" format=csv
```
| Parameter | Required | Description |
| -------------- | -------- | --------------------------------------------------- |
| `file=<name>` | No\* | Target Base file by name (without extension). |
| `path=<path>` | No\* | Target Base file by vault-relative path. |
| `view=<name>` | No | View name to query. Omit for default view. |
| `format=<fmt>` | No | Output format (e.g., `csv`). Omit for default text. |
| `total` | No | Return only the row count. |
\* One of `file` or `path` is required.
**Output (default text)**: Tabular data, one row per line.
**Output (csv)**: CSV-formatted data.
```
Name,Status
Alpha,Active
Beta,Done
```
**Output (total)**: Single number.
---
### A.8 Error Responses
All commands return consistent error formats:
| Condition | Output |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| File not found | `Error: File "path/to/file.md" not found.` |
| Missing required param | `Error: Missing required parameter: name=<name>` with usage line |
| No results | Command-specific empty message (e.g., `No tasks found.`, `No backlinks found.`, `No links found.`) |
| CLI binary not found | Process error code `ENOENT` — handled by `ObsidianCliClient` fallback resolution |
| Timeout | Process killed after `timeoutMs` — handled by `ObsidianCliClient` |

View file

@ -8,29 +8,36 @@ The Copilot tool system uses a centralized registry pattern that makes it easy t
### How Tool Instructions Flow to the LLM
The system uses a three-layer approach for providing tool instructions to LLMs:
The system uses **native tool calling** via LangChain's `bindTools()` for tool invocation. Tool results are formatted as context in a layered approach:
1. **Tool Schema Descriptions** (in tool implementations like `ComposerTools.ts`)
1. **Tool Schema Descriptions** (Zod schemas in tool implementations)
- Defines parameter formats, rules, and validation
- NO XML examples - focuses on data contract only
- Provided to LLM via `bindTools()` for native tool calling
2. **Custom Prompt Instructions** (in `builtinTools.ts`)
- Contains XML `<use_tool>` invocation examples
- Shows when and how to call the tool
- Behavioral guidance for when and how to use tools
- Special requirements (e.g., "always provide salientTerms")
3. **Model-Specific Adaptations** (in `modelAdapter.ts`)
- Last resort for model-specific quirks
### Layered Prompt Integration
- `ContextManager` promotes user-attached artifacts from earlier turns into **L2 (Context Library)**, so every chain runner starts with the same cacheable system prefix.
- When a tool executes during the current turn, its result payload is prepended to the **user message** (L3 + L5) using `renderCiCMessage(...)`. Nothing is injected into the system message, keeping L1/L2 stable.
- `LayerToMessagesConverter.convert(envelope, { includeSystemMessage: true, mergeUserContent: true })` materializes the base messages; runners then append tool results before sending to the model.
- `promptPayloadRecorder` inspects the final payload and highlights tool blocks in its layered view, making it easy to debug the L1-L5 structure.
### Why Two Layers: Schema vs Custom Instructions
**Key Difference**: Schema descriptions document parameters, while custom instructions provide XML invocation examples.
**Key Difference**: Schema descriptions document parameters via Zod, while custom instructions provide behavioral guidance.
1. **Clear Separation**
- **Schema**: Parameter documentation (types, formats, rules) - NO XML examples
- **Custom Instructions**: XML `<use_tool>` examples showing how to invoke
- **Schema**: Parameter documentation (types, formats, rules) via Zod - used by `bindTools()`
- **Custom Instructions**: Behavioral guidance on when and how to use the tool
2. **MCP Compatibility**
@ -40,37 +47,50 @@ The system uses a three-layer approach for providing tool instructions to LLMs:
3. **Example**
```typescript
// Schema (parameter documentation only)
salientTerms: z.array(z.string()).describe("Keywords to find in notes");
// Schema (provided to LLM via bindTools())
const searchSchema = z.object({
query: z.string().min(1).describe("The search query"),
salientTerms: z.array(z.string()).describe("Keywords to find in notes"),
});
// Custom Instructions (XML invocation examples)
// Custom Instructions (behavioral guidance)
customPromptInstructions: `
Example usage:
<use_tool>
<name>localSearch</name>
<query>piano learning</query>
<salientTerms>["piano", "learning"]</salientTerms>
</use_tool>`;
When searching notes:
- Always provide salientTerms extracted from the user's query
- Use getTimeRangeMs first for time-based queries
- Examine relevance scores before using results
`;
```
### Best Practices
1. **Schema Descriptions: Parameter Documentation Only**
- Document parameter types, formats, and validation rules
- NO XML examples - those belong in custom instructions
- Document parameter types, formats, and validation rules via Zod
- Schemas are automatically provided to LLM via `bindTools()`
- Focus on the data contract
2. **Custom Instructions: XML Examples & Usage Patterns**
2. **Custom Instructions: Behavioral Guidance**
- Provide XML `<use_tool>` invocation examples
- Show common usage patterns and edge cases
- Include behavioral guidance (when to use vs other tools)
- Explain when to use this tool vs alternatives
- Show common usage patterns and requirements
- Include tips for better results (e.g., "use getTimeRangeMs before localSearch")
3. **Model Adapters: Model-Specific Fixes**
- Only for persistent model-specific failures
- Keep minimal and targeted
### localSearch CiC Prompting Flow
- CiC: Corpus in Context https://arxiv.org/pdf/2406.13121
- **Instruction First**: `CopilotPlusChainRunner` now assembles the localSearch payload via `buildLocalSearchInnerContent`, ensuring citation guidance (e.g., `<guidance>` rules) tops the XML block before any documents.
- **Documents Next**: Search hits are serialized once through `formatSearchResultsForLLM`; the helper simply appends them after guidance, keeping the documents section untouched but clearly separated.
- **Question Last**: `renderCiCMessage` formats the final prompt so any context precedes the user's original query; this matches the CiC recommendation for instruction → context → query ordering.
- **CopilotPlus**: Uses `LayerToMessagesConverter` which adds `[User query]:` label when merging L3+L5 content from envelope
- **AutonomousAgent**: Uses `ensureCiCOrderingWithQuestion` which adds `[User query]:` label to clearly separate tool results from original query in iterative loop
- **Consistency**: Both chains use the same `[User query]:` label format for uniform prompting across the codebase
- **Reusable Wrapping**: `wrapLocalSearchPayload` centralizes the `<localSearch>` tag creation (including optional `timeRange`), making the layout reusable for future chains without copying string glue.
## Current Implementation
### Core Files
@ -318,64 +338,26 @@ function updateMcpToolSetting(toolId: string, enabled: boolean) {
3. **Settings UI**: `ToolSettingsSection` component reads from the registry to generate UI
4. **Tool Execution**: `AutonomousAgentChainRunner.getAvailableTools()` filters tools based on settings
### Tool Execution Flow
## Tool Call Rendering Roots
1. Agent calls `getAvailableTools()` which:
React invariant #409 surfaced when tool-call banners attempted to render into React roots that had already been unmounted. To prevent this regression, the plugin routes all banner rendering through the shared manager in `src/components/chat-components/toolCallRootManager.tsx`.
- Gets enabled tool IDs from settings array (`autonomousAgentEnabledToolIds`)
- Calls `registry.getEnabledTools()` to get actual tool implementations
- Filters based on vault availability and user preferences
### Manager Responsibilities
2. Model adapter receives tool list and:
- Tracks `{ root, isUnmounting }` per message/tool call via `window.__copilotToolCallRoots`.
- `ensureToolCallRoot` finalises pending disposals and creates a new `createRoot` when needed.
- `renderToolCallBanner` renders `<ToolCallBanner />` into the managed root; components never call `root.render` directly.
- `removeToolCallRoot` and `cleanupMessageToolCallRoots` schedule unmounts on the next tick and drop entries only after disposal completes.
- `cleanupStaleToolCallRoots` purges message IDs older than one hour to avoid leaking historical roots.
- Generates tool descriptions for the system prompt
- Includes tool-specific instructions based on enabled tools
### Integration Notes
3. When tool is called:
- XML parsing extracts tool name and parameters
- Tool is executed via its `func` implementation
- Results are formatted and returned to the agent
`ChatSingleMessage` keeps `const rootsRef = useRef(getMessageToolCallRoots(messageId))`, which provides a stable registry for each message. The component delegates all lifecycle calls to the manager and snapshots `rootsRef.current` inside effect cleanup to satisfy `react-hooks/exhaustive-deps`.
## Benefits of This Architecture
### Verification
1. **Modularity**: Each tool is self-contained with metadata
2. **Extensibility**: New tools can be added without core changes
3. **Backward Compatibility**: Settings structure preserved while supporting new tools
4. **Dynamic UI**: Settings automatically adapt to registered tools
5. **Smart Prompts**: System prompts include only relevant tool instructions
6. **MCP Ready**: Architecture supports dynamic tool registration from external sources
Run the focused test to cover the streaming behaviour and tool-call integration:
## Testing Your Tools
```typescript
// Test tool registration
const registry = ToolRegistry.getInstance();
registry.clear();
initializeBuiltinTools(vault);
// Verify tool is registered
const allTools = registry.getAllTools();
console.log(
"Registered tools:",
allTools.map((t) => t.metadata.id)
);
// Test with settings
const enabledIds = new Set(["myNewTool", "localSearch"]);
const enabledTools = registry.getEnabledTools(enabledIds, true);
console.log(
"Enabled tools:",
enabledTools.map((t) => t.name)
);
```
This architecture provides a clean, extensible foundation for the tool system while maintaining simplicity and backward compatibility.
## Summary
The tool system's layered approach allows for:
- Clear, comprehensive tool documentation at the schema level
- Model-agnostic instructions that work for most LLMs
- Targeted model-specific adaptations when necessary
- Easy extension for new tools without modifying core infrastructure
npm test -- src/components/chat-components/ChatSingleMessage.test.tsx
```

View file

@ -0,0 +1,583 @@
# ACP Integration Design (Final)
Status: Final draft for implementation
Date: 2026-02-19
## 1. Goal
Add ACP-based agents (Claude Code, Codex, OpenCode first) as a first-class interaction paradigm in Copilot.
Core direction:
- ACP is a parallel runtime path.
- Existing LangChain-based chat modes remain intact.
- Long-term architecture is optimized for ACP as the primary agent path.
## 2. Final Decisions
1. Use `InteractionMode` (`llm` vs `agent`) instead of adding `ChainType.ACP`.
2. Keep ACP runtime isolated from LangChain model/tool/memory/envelope stacks.
3. Support file read/write + permission in agent mode.
4. OpenCode model switching is required when ACP session model capabilities are available.
## 3. What ACP Changes (and What It Does Not)
### 3.1 Bypassed in `agent` mode
- `ChainManager` / `ChainRunner` request pipeline
- `ChatModelManager` and provider model selection
- `ContextManager` L1-L5 envelope construction
- `LayerToMessagesConverter`
- LangChain memory sync (`MemoryManager`, `updateChatMemory`)
- LangChain-native `ToolRegistry` tool planning/calling
- `getAIResponse()` in `src/langchainStream.ts`
### 3.2 Reused in `agent` mode
- Chat shell UI and message list containers
- `MessageRepository` for display storage
- `ChatUIState` subscription model
- `ChatManager` as orchestration hub (with an ACP-specific send path)
- Existing settings infrastructure
## 4. Runtime Architecture
### 4.1 Interaction Mode
Add top-level interaction mode in `src/aiParams.ts`:
```ts
type InteractionMode = "llm" | "agent";
```
- `llm` mode: current Copilot behavior unchanged.
- `agent` mode: ACP pipeline and controls.
### 4.2 ACP Runtime Modules
Create a dedicated ACP namespace (`src/acp/`):
- `src/acp/ports/agent-client.port.ts` — IAgentClient interface (main contract)
- `src/acp/adapters/acp.adapter.ts` — process spawn, ACP handshake, ndJSON stream, session update routing, permission queue
- `src/acp/adapters/terminal-manager.ts` — handles ACP terminal callbacks: `terminal/create`, `terminal/output`, `terminal/kill`, `terminal/wait_for_exit`
- `src/acp/types/*` — domain types (AgentConfig, SessionUpdate, PromptContent, etc.)
- `src/acp/session/ACPManager.ts` — adapter lifecycle, agent switching, config resolution
- `src/acp/context/AcpPromptAssembler.ts` — builds ACP prompt content from user input + attached context
- `src/acp/updates/AcpUpdateReducer.ts` — routes session update notifications to message state
- `src/acp/components/*` — agent selector, model/mode selectors, tool/permission/terminal renderers
Design rules:
- ACP modules must not depend on LangChain runtime components.
- All ACP protocol details isolated in `adapters/` layer; domain and UI layers use port interfaces only.
### 4.3 ChatManager Parallel Path
Keep existing `sendMessage()` unchanged (LLM mode).
Add `sendAgentMessage()` for ACP mode:
1. Create/store user message (display text).
2. Build ACP prompt content via `AcpPromptAssembler`.
3. Delegate to ACP manager for session/prompt streaming.
## 5. Context Strategy in Agent Mode
ACP agents manage their own context windows and tools. Copilot should only provide turn-scoped context.
Agent-mode prompt policy:
- Include current user message.
- Include current-turn attached context only (notes/web selections/images).
- Do not build or inject L2 cumulative context library.
- Do not inject Copilot L4 conversation strip.
- Do not force Copilot system prompt by default.
Attached context conversion (handled by `AcpPromptAssembler`):
- @mentioned notes → ACP `Resource` blocks if agent supports `embeddedContext` capability, otherwise embedded as text in the prompt.
- Images → ACP image content blocks if agent supports `image` capability.
- Web selections → embedded as text content blocks.
Optional advanced setting (off by default):
- "Prepend Copilot custom system instructions in agent mode"
This avoids context duplication and keeps prompt behavior agent-native.
## 6. File Read/Write and Permission Model
This section reflects observed ACP integration patterns and the final Copilot design requirements.
### 6.1 Finding: Typical ACP File Operation Patterns
Observed in common ACP client flows:
- During ACP initialize, clients may advertise:
- `fs.readTextFile = false`
- `fs.writeTextFile = false`
- and still provide rich editing UX through:
- agent `tool_call` / `tool_call_update` events (including diff content),
- ACP `session/request_permission`,
- permission UI actions mapped back to ACP permission responses.
Meaning:
- file editing permission flow is often agent-tool-based, not strictly dependent on ACP `fs/*` callbacks.
### 6.2 Copilot requirement and final design
Copilot agent mode must support file read/write with permission controls.
We support this through two channels:
1. Agent-tool permission channel (MVP required)
- Handle and render `tool_call`/`tool_call_update` diffs and statuses.
- Handle `session/request_permission` with explicit approve/reject UI.
- Default secure posture: no auto-allow by default.
2. ACP fs callback channel (compatibility extension)
- Implement real `fs/readTextFile` and `fs/writeTextFile` handlers against Obsidian vault APIs.
- Gate writes behind the same explicit permission workflow.
- Keep capability flags accurate per actual implementation.
Rationale:
- Channel 1 matches real behavior of target agents and is required immediately.
- Channel 2 improves compatibility for agents that use ACP fs APIs directly.
### 6.3 Tool Call Architecture in Agent Mode
In `agent` mode, there are two tool lanes:
1. ACP-native agent tools (primary lane)
- Agent executes tools internally.
- Agent emits `tool_call` / `tool_call_update`.
- Copilot renders tool blocks and permission state.
2. Agent Skills (context-based, agent-executed)
- Skills are **markdown files and scripts** in a user-configured folder, similar to Claude Code's skill system.
- Copilot surfaces skill files to the agent as context. The **agent** reads and executes them — Copilot does not run tools client-side.
- Progressive disclosure: not all skills are dumped at once. Relevant skills are surfaced based on conversation context.
#### 6.3.1 Agent Skills Design
**What a skill is:**
- A `.md` file containing instructions, templates, or domain knowledge.
- Optionally accompanied by scripts (shell, python, etc.) that the agent can execute via its own tool system.
- Organized in a configurable skills folder within the vault (e.g., `copilot-skills/`).
**Example skill structure:**
```
copilot-skills/
├── vault-search.md # How to use miyo CLI/MCP for hybrid vault search
├── web-search.md # Self-hosted web search endpoint and usage
├── youtube-transcription.md # Self-hosted YouTube transcription service
├── code-review.md # Instructions for how to review code in this project
├── commit-conventions.md # Commit message format and rules
├── vault-organization.md # How notes are structured in this vault
├── scripts/
│ ├── run-tests.sh # Test runner the agent can invoke
│ └── lint-check.sh # Linting script
└── templates/
└── meeting-note.md # Template the agent can use when creating notes
```
**Example: vault-search.md (miyo integration)**
```markdown
# Vault Search
Use miyo for hybrid (semantic + keyword) search over this vault.
## CLI
miyo search "<query>" --limit 10
## MCP
miyo is also available as an MCP server for structured tool access.
```
**Example: web-search.md (self-hosted service)**
```markdown
# Web Search
Self-hosted web search via Firecrawl.
Endpoint: http://localhost:3002/v1/search
Method: POST
Body: {"query": "...", "limit": 5}
Returns: JSON array of {title, url, content}
```
This pattern covers all local tools and self-hosted services uniformly:
- **miyo** for vault hybrid search (CLI or MCP — agent chooses)
- **Self-hosted Firecrawl** for web search
- **Self-hosted Supadata** for YouTube transcription
- Any future local service the user runs
Copilot never needs to bridge or proxy these. The agent calls them directly.
**How skills reach the agent:**
- Since working directory = vault root, the agent has filesystem access to skill files directly.
- Copilot tells the agent about the skills folder location in the prompt context.
- Copilot can optionally list available skill filenames so the agent knows what's there.
- The agent decides which skills to read and follow — full agency stays with the agent.
**Progressive disclosure strategy:**
- Level 1: Always include a skill index (list of filenames + one-line descriptions) in the prompt.
- Level 2: Include full content of skills tagged as "always active" (e.g., project conventions).
- Level 3: Agent reads additional skill files on demand via its own file read tools.
**Skill management UI:**
- Settings: configure skills folder path.
- Skills browser: list discovered skills, toggle "always active" flag per skill.
- No client-side execution — Copilot never runs skill scripts. The agent does.
**Key principle:**
- Skills are context, not tools. Copilot provides them. The agent acts on them.
- This keeps the ACP path clean — no client-side tool execution, no LangChain coupling.
- Analogous to how Claude Code reads CLAUDE.md files for project-specific instructions.
## 7. Agent and Session State
### 7.1 Agent presets
Built-in defaults:
- Claude Code
- Codex
- OpenCode
Each preset is user-editable:
- `id`, `displayName`, `command`, `args`, `env`.
Do not hardcode a single arg style across versions.
- OpenCode commonly uses an `acp` subcommand in examples.
- Some agents may use flags such as `--acp`.
- Presets are defaults, not strict assumptions.
### 7.2 Capabilities
Track capabilities from ACP initialize/new session:
- prompt capabilities (image/resource)
- session capabilities (mode/model/list/load/resume/fork if available)
All UI controls are capability-gated.
### 7.3 OpenCode model switching
Requirement implementation:
- Use ACP session models from `newSession` / loaded session data.
- Show model selector when multiple models are exposed.
- Switch with `unstable_setSessionModel`.
- Use optimistic UI update with rollback on error.
Important protocol note:
- `current_mode_update` is mode-specific.
- Do not rely on it as model-change confirmation.
### 7.4 Error handling and reconnection
Agent process errors are surfaced explicitly in the chat UI. No silent recovery.
**Error categories:**
- **Spawn failure** (command not found, permission denied): Show error with setup guidance. Detect via exit code 127, `ENOENT`.
- **Agent crash** (unexpected process exit): Show error in chat. Session state → "disconnected".
- **Protocol error** (ACP JSON-RPC errors): Show error message from agent. Session remains connected if process alive.
- **Silent failure** (agent returns empty response): Detect via zero session updates + `end_turn`. Check stderr for API key / auth hints.
**Reconnection strategy:**
- No automatic reconnection. Agent processes are stateful — blindly restarting loses session context.
- User-initiated: "Restart Agent" action kills process, re-spawns, creates new session.
- If agent supports `loadSession`: offer "Restart and Reload" to re-spawn + reload previous session history.
- Display clear connection status indicator (connected / busy / disconnected / error) in ChatControls.
## 8. UI Behavior
### 8.1 ChatControls
When `interactionMode === "llm"`:
- keep existing chain/model controls.
When `interactionMode === "agent"`:
- show agent selector.
- show mode selector when ACP modes available.
- show model selector when ACP models available.
- show session/connection status indicator.
- hide Copilot Plus LangChain tool toggles and command injection controls.
- show skills folder indicator (configured/not configured, skill count).
### 8.2 Message rendering in agent mode
Render ACP updates as first-class content:
- assistant text stream (`agent_message_chunk`)
- thought stream (`agent_thought_chunk`)
- tool call blocks (`tool_call`, `tool_call_update`)
- permission controls (`request_permission`)
- terminal output blocks
- plan blocks
Do not reuse legacy marker-based agent rendering as the primary ACP path.
Additionally show skill context metadata when skills are active:
- skills folder path indicator
- list of "always active" skills included in prompt context
## 9. Message Flow
### 9.1 LLM mode (unchanged)
Existing path remains as-is:
- `Chat` -> `ChatUIState` -> `ChatManager.sendMessage()` -> LangChain flow.
### 9.2 Agent mode
New path:
1. `Chat` checks `interactionMode === "agent"`.
2. `ChatManager.sendAgentMessage()` stores user message and builds ACP prompt content.
3. ACP manager ensures process/session and sends `session/prompt`.
4. Session updates stream back into message state.
5. Cancel maps to ACP `session/cancel`.
## 10. Settings Additions
Extend `CopilotSettings` with ACP section:
- `interactionModeDefault` (optional)
- `acpDefaultAgentId`
- `acpAgents: ACPAgentConfig[]`
- `acpAutoAllowPermissions` (default false)
- `acpSkillsFolderPath` (default `"copilot-skills"`, vault-relative)
- optional ACP diagnostics/logging toggles
## 11. Implementation Plan
### Phase 1: Core ACP lane
**Goal**: End-to-end text streaming with a single agent (Claude Code).
**New files:**
| File | Ported from reference | Description |
|---|---|---|
| `src/acp/ports/agent-client.port.ts` | `domain/ports/agent-client.port.ts` | IAgentClient interface |
| `src/acp/types/agentConfig.ts` | `domain/models/agent-config.ts` | AgentConfig, BaseAgentSettings |
| `src/acp/types/sessionUpdate.ts` | `domain/models/session-update.ts` | SessionUpdate union type |
| `src/acp/types/promptContent.ts` | `domain/models/prompt-content.ts` | PromptContent types |
| `src/acp/types/sessionState.ts` | `domain/models/chat-session.ts` | Mode/model state types |
| `src/acp/types/agentError.ts` | `domain/models/agent-error.ts` | Error types |
| `src/acp/adapters/acp.adapter.ts` | `adapters/acp/acp.adapter.ts` | Core ACP adapter (~1200 lines) |
| `src/acp/adapters/acp-type-converter.ts` | `adapters/acp/acp-type-converter.ts` | Domain ↔ SDK types |
| `src/acp/utils/shellUtils.ts` | `shared/shell-utils.ts` | Login shell wrapping |
| `src/acp/utils/errorUtils.ts` | `shared/acp-error-utils.ts` | Error parsing |
| `src/acp/session/ACPManager.ts` | new | Adapter lifecycle singleton |
| `src/acp/components/AgentSelector.tsx` | new | Agent picker dropdown |
**Modified files:**
| File | Change |
|---|---|
| `src/aiParams.ts` | Add `InteractionMode` type + Jotai atoms |
| `src/core/ChatManager.ts` | Add `sendAgentMessage()` method |
| `src/components/Chat.tsx` | Branch `handleSendMessage` on interaction mode |
| `src/components/chat-components/ChatControls.tsx` | Add mode toggle + agent selector |
| `src/settings/model.ts` | Add ACP settings fields |
| `package.json` | Add `@agentclientprotocol/sdk` dependency |
**Workload**: Largest phase. ~15 new files, ~5 modified files. The ACP adapter is the heaviest piece (~1200 lines ported from reference, adapted for Copilot patterns). Shell utils and type definitions are mostly direct ports. ACPManager and ChatManager integration are new code.
**Exit criteria**: Select Claude Code in agent mode → type message → see streaming text response in chat.
---
### Phase 2: Permissions + tool call rendering
**Goal**: Full tool call UX — diffs, terminal output, permission prompts.
**New files:**
| File | Ported from reference | Description |
|---|---|---|
| `src/acp/adapters/terminal-manager.ts` | `shared/terminal-manager.ts` | Terminal lifecycle + output buffering |
| `src/acp/components/ToolCallBlock.tsx` | new (reference has `ToolCallRenderer.tsx`) | Tool call status, kind, title, locations |
| `src/acp/components/DiffViewer.tsx` | new (reference has `DiffBlock.tsx`) | File diff rendering |
| `src/acp/components/TerminalOutput.tsx` | new (reference has `TerminalBlock.tsx`) | Terminal command output |
| `src/acp/components/PermissionRequestUI.tsx` | new (reference has `PermissionRequestSection.tsx`) | Approve/deny inline buttons |
| `src/acp/components/PlanBlock.tsx` | new (reference has `PlanBlock.tsx`) | Execution plan task list |
| `src/acp/updates/AcpUpdateReducer.ts` | new | Routes session updates to message content |
**Modified files:**
| File | Change |
|---|---|
| `src/components/chat-components/ChatMessages.tsx` | Detect and render ACP content types |
| `src/settings/model.ts` | Add `acpAutoAllowPermissions` |
**Workload**: Medium-heavy. ~7 new component files. TerminalManager is substantial (~500 lines from reference). UI components are mostly new but follow patterns from reference plugin. AcpUpdateReducer is new routing logic.
**Exit criteria**: Agent performs file edit → see diff in chat → permission prompt appears → approve → agent continues. Terminal commands show output blocks.
---
### Phase 3: Mode/model controls + settings
**Goal**: Full agent configuration, OpenCode model switching, mode selection.
**New files:**
| File | Description |
|---|---|
| `src/acp/components/AgentModelSelector.tsx` | Model dropdown populated from ACP session models |
| `src/acp/components/AgentModeSelector.tsx` | Mode dropdown populated from ACP session modes |
| `src/acp/components/AgentSettingsTab.tsx` | Full settings UI for agent configuration |
**Modified files:**
| File | Change |
|---|---|
| `src/acp/adapters/acp.adapter.ts` | Add `setSessionMode()`, `setSessionModel()` calls |
| `src/components/chat-components/ChatControls.tsx` | Wire model/mode selectors, connection status |
| `src/settings/model.ts` | Add built-in agent presets, custom agent support |
**Workload**: Medium. 3 new UI components, moderate adapter additions. Settings tab is the largest piece — per-agent command/args/env/apikey editing. Model/mode selectors are small but need optimistic UI + rollback.
**Exit criteria**: Select OpenCode → see model dropdown → switch to minimax-2.5 → agent confirms model change. Edit Claude Code command path in settings → reconnect works.
---
### Phase 4: Agent Skills
**Goal**: Skills folder discovery, index generation, always-active injection, skills browser.
**New files:**
| File | Description |
|---|---|
| `src/acp/context/AcpPromptAssembler.ts` | Builds prompt content: user text + mentions + skill index + active skills |
| `src/acp/context/skillDiscovery.ts` | Scans skills folder, extracts index (filename + first-line description) |
| `src/acp/components/SkillsBrowser.tsx` | List skills, toggle "always active" per skill |
**Modified files:**
| File | Change |
|---|---|
| `src/acp/session/ACPManager.ts` | Inject skill context into prompts |
| `src/settings/model.ts` | Add `acpSkillsFolderPath` setting |
**Workload**: Medium-light. Skill discovery is straightforward filesystem scanning. AcpPromptAssembler assembles the prompt with skill index + active skill content. Browser UI is a simple list with toggles.
**Exit criteria**: Configure `copilot-skills/` folder → skills appear in browser → mark `vault-search.md` as always-active → send message → agent sees skill index + vault-search.md content in prompt → agent uses miyo to search.
---
### Phase 5: ACP fs callback compatibility
**Goal**: Real vault read/write via ACP `fs/*` callbacks for agents that use them.
**New files:**
| File | Ported from reference | Description |
|---|---|---|
| `src/acp/adapters/vault-adapter.ts` | `adapters/obsidian/vault.adapter.ts` | Bridges ACP fs to Obsidian vault API |
**Modified files:**
| File | Change |
|---|---|
| `src/acp/adapters/acp.adapter.ts` | Enable `fs.readTextFile`/`fs.writeTextFile` capabilities, wire to vault adapter |
**Workload**: Light. Single adapter file. Read is simple vault file read. Write needs permission prompt before executing (reuse permission UI from Phase 2).
**Exit criteria**: Agent using ACP fs API reads vault file → gets content. Agent writes file → permission prompt → approve → file written to vault.
---
### Phase 6: Session lifecycle enhancements
**Goal**: Session persistence, load/resume, session history browser.
**New files:**
| File | Description |
|---|---|
| `src/acp/components/SessionHistoryModal.tsx` | Session list + load/resume actions |
**Modified files:**
| File | Change |
|---|---|
| `src/acp/adapters/acp.adapter.ts` | Add `listSessions()`, `loadSession()`, `resumeSession()`, `forkSession()` |
| `src/acp/session/ACPManager.ts` | Session metadata persistence, capability-gated session operations |
| `src/settings/model.ts` | Add saved session metadata storage |
**Workload**: Medium. Session list/load requires adapter additions and UI. All session features are capability-gated — only show UI if agent supports them. Fork is unstable and lowest priority within this phase.
**Exit criteria**: Close and reopen Obsidian → switch to agent mode → "Load Session" shows previous sessions → select one → conversation history replays → can continue conversation.
## 12. Risks and Mitigations
- ACP unstable methods: isolate protocol details in adapter layer.
- Process lifecycle errors: explicit reconnect and error surfacing (see 7.4).
- Permission safety: default deny/explicit user action.
- Context bloat: strict per-turn context assembly only.
- UI complexity: mode-gated controls and capability-based rendering.
## 13. Future Decisions (Post-MVP)
The following items are intentionally deferred from MVP and should be revisited in later phases:
1. `@` command behavior in agent mode
- Decide whether `@vault/@websearch/@composer/@memory` remain plain text only, or trigger skill-hint injection behavior.
2. Skill file schema contract
- Decide whether skills require frontmatter metadata (`name`, `description`, `alwaysActive`, `tags`) vs free-form markdown.
3. Progressive disclosure algorithm
- Define how skill relevance is selected (keyword, tags, manual pinning, hybrid scoring).
4. Skill prompt budget limits
- Set hard caps for skill index size and total always-active skill content injected per turn.
5. Tool integration boundary
- Confirm whether agent mode stays context-only for skills, or eventually allows selective client-side Copilot tool execution.
6. Script safety model
- Define Copilot-level safeguards for skill scripts (trusted folders, warnings, policy prompts) in addition to agent permission flow.
7. Skills scope model
- Decide global vault-wide skills vs project/profile-specific skill sets.
8. Skills folder constraints
- Decide vault-relative only skills folders vs external/absolute path support.
## 14. Acceptance Criteria
- Agent mode can run Claude Code, Codex, OpenCode.
- OpenCode model switching works when the agent exposes session models.
- File operations are permission-controlled and visible in chat.
- LLM modes are behaviorally unchanged.
- ACP mode avoids LangChain context/model/tool/memory pipelines.
- ACP mode never invokes LangChain autonomous tool planning/execution loops.
- Agent Skills (md files + scripts) are surfaced as context; agent reads and executes them, not Copilot.

View file

@ -0,0 +1,239 @@
# Agent Planning + Reflection Visibility (v0)
**Date:** 2026-02-10
**Status:** Draft
**Scope:** Autonomous Agent (`AutonomousAgentChainRunner`) only
## 1. Problem Statement
The current autonomous agent loop is functional and simple, but it has two gaps:
1. No explicit machine-readable plan state in the ReAct loop.
2. Reasoning visibility is mostly tool-call/result summaries, with weak iteration-level reflection.
Today, planning is implicit in model text and tool order. The UI (`AgentReasoningBlock`) only sees serialized step strings, so users cannot clearly track "what is the current plan" vs "what just happened".
## 2. Current Baseline (What We Have)
- ReAct loop with native tool calling in `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts`.
- Reasoning block state/serialization in `src/LLMProviders/chainRunner/utils/AgentReasoningState.ts`.
- Reasoning UI rendering in `src/components/chat-components/AgentReasoningBlock.tsx` and parsing in `src/components/chat-components/ChatSingleMessage.tsx`.
- Tool registry and metadata model in `src/tools/ToolRegistry.ts` and `src/tools/builtinTools.ts`.
This is already a solid base for a minimal planner because:
- The loop already supports iterative tool decisions.
- The reasoning block already supports rolling vs full history.
- Tools are already typed with Zod and routed through one registry.
## 3. Goals
1. Add a minimal planning primitive (`write_todos`) that fits the existing sequential ReAct loop.
2. Improve per-iteration reasoning visibility without exposing chain-of-thought.
3. Keep the implementation robust with minimal new state.
4. Prepare a clean extension point for future subagents and context encapsulation.
## 4. Non-Goals (v0)
1. No multi-agent orchestration in this phase.
2. No persistent cross-turn planner memory.
3. No complex planner DAG or dependency graph.
4. No major UI rewrite of Reasoning Block.
## 5. v0 Design Overview
### 5.1 Add a Minimal Planner Tool: `write_todos`
Introduce a lightweight built-in tool that updates the agent's execution checklist.
Tool semantics:
- Input is the full current todo snapshot (replace semantics, not patch semantics).
- Output is a compact structured acknowledgement.
- No file I/O, no vault mutation, no side effects outside the in-memory run state.
Example schema:
```ts
const writeTodosSchema = z.object({
todos: z
.array(
z.object({
id: z.string().min(1).max(40),
content: z.string().min(1).max(140),
status: z.enum(["pending", "in_progress", "completed"]),
})
)
.min(1)
.max(8),
focus: z.string().max(40).optional(),
note: z.string().max(200).optional(),
});
```
Example result payload:
```json
{
"ok": true,
"revision": 3,
"todoCount": 4,
"inProgress": "read_note_context"
}
```
Why replace semantics:
- Easier for model to reason about.
- Deterministic state transitions.
- No merge/conflict logic in runner.
### 5.2 ReAct Loop Integration (Minimal Changes)
In `runReActLoop`:
- Keep one loop and one tool execution path.
- Special-case `write_todos` before normal tool execution.
- Convert planner updates into reasoning events and a compact `ToolMessage` acknowledgement.
Pseudo-flow:
1. Model returns `tool_calls`.
2. If call is `write_todos`, apply/update in-memory planner state.
3. Emit reasoning step(s) with `[Plan]` prefix.
4. Push tool result message so model can continue.
5. Continue loop unchanged for normal tools.
Guardrails:
- Max 2 consecutive planner-only iterations.
- If planner loops, return tool error: `"planner_overuse_execute_next_step"`.
- If planner args invalid, return schema error and continue loop.
### 5.3 Better Reflection Visibility in Existing Reasoning Block
Keep the existing component but make steps more legible by phase-tagging events.
Step tags (string prefix only, no UI rewrite required):
- `[Plan]` todo updates and step ordering
- `[Act]` tool call intent
- `[Obs]` tool result summary
- `[Reflect]` model's concise iteration reflection
Implementation detail:
- Reuse current `addReasoningStep` and `allReasoningSteps`.
- Add small extraction helper for reflection text from `AIMessage.content` per iteration.
- Enforce short reflection summaries (single sentence, capped length).
This gives better visibility immediately with minimal parser/rendering changes.
### 5.4 Prompting Updates
Add tool guidance for `write_todos` via tool metadata and agent prompt section.
Rules:
1. Use `write_todos` for multi-step tasks (>=2 meaningful actions).
2. First planner call should happen before the first expensive external tool when task is non-trivial.
3. Keep todos short and action-oriented.
4. Update statuses as execution progresses.
5. Do not repeatedly rewrite unchanged todos.
## 6. Data Model (v0 Sidecar State)
Add in-memory runtime state in `AutonomousAgentChainRunner`:
```ts
interface PlannerState {
revision: number;
todos: Array<{ id: string; content: string; status: "pending" | "in_progress" | "completed" }>;
focus?: string;
updatedAt: number;
}
interface ReasoningEvent {
phase: "plan" | "act" | "obs" | "reflect";
summary: string;
iteration: number;
timestamp: number;
}
```
No persistence changes are required for v0. Existing chat persistence already strips reasoning markers.
## 7. Extensibility Path: Context Capsule for Future Subagents
To support near-future subagents without redesigning the loop, add one abstraction now:
```ts
interface ContextCapsule {
goal: string;
planSnapshot?: PlannerState;
keyFindings: string[];
artifacts: Array<{ type: string; ref: string; summary: string }>;
nextActions?: string[];
}
```
v0 usage:
- Single agent creates this in-memory as a byproduct (optional, debug-only).
Future subagent usage:
- Parent agent passes a scoped goal.
- Subagent returns only a compact `ContextCapsule` (not full transcript).
- Parent injects capsule summary into next decision turn as tool result.
This keeps context encapsulated and token usage bounded.
## 8. Minimal File-Level Change Plan
1. Add `src/tools/PlannerTools.ts` with `write_todos` tool.
2. Register tool in `src/tools/builtinTools.ts`.
3. Update `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts`:
- planner sidecar state
- special handling for `write_todos`
- tagged reasoning events (`[Plan]/[Act]/[Obs]/[Reflect]`)
4. Optional small helper updates in `src/LLMProviders/chainRunner/utils/AgentReasoningState.ts` for reflection extraction formatting.
5. Add tests:
- planner tool schema/validation
- loop behavior with planner-only + mixed tool calls
- reasoning step tagging regression
## 9. Acceptance Criteria
1. Complex user query shows at least one `[Plan]`, one `[Act]`, and one `[Obs]` in reasoning steps.
2. Planner updates do not break normal ReAct completion behavior.
3. Agent still terminates on timeout/max-iterations as before.
4. No regression in non-planner queries.
## 10. Risks and Mitigations
1. Model ignores planner tool:
- Mitigation: planner is optional; loop still works exactly as today.
2. Planner spam:
- Mitigation: cap consecutive planner-only iterations.
3. Token bloat from verbose todos:
- Mitigation: hard limits on item count and text length.
4. Over-exposure of hidden reasoning:
- Mitigation: only allow concise operational reflection summaries.
## 11. Rollout
1. Ship behind a feature flag (e.g., `enableAgentPlannerV0`).
2. Enable for internal testing first.
3. Validate on representative flows: search-heavy, note reading, and composer edit tasks.
4. Enable by default after stability pass.
## 12. Open Questions
1. Should `write_todos` be always enabled or user-configurable?
2. Should planner state be exposed in any UI beyond Reasoning Block?
3. Should we persist final plan snapshot in message metadata for debugging?
---
This v0 keeps the architecture simple: one sequential ReAct loop, one lightweight planning tool, and better reasoning visibility now, while setting up a clean context-capsule path for subagents later.

View file

@ -0,0 +1,572 @@
# Agent Reasoning Block Implementation Plan
## Overview
Replace the current tool call banner with a new **Agent Reasoning Block** - a multi-line collapsible block that shows the agent's reasoning process during execution, then collapses to "Thought for N s" during final response streaming.
---
## Design Reference
**During Agent Loop (Expanded):**
```
┌──────────────────────────────────────────────────┐
│ ⠿ Reasoning · 9s │
│ │
│ • Searching notes for "machine learning" │
│ • Found 5 relevant notes, analyzing content │
└──────────────────────────────────────────────────┘
```
**During Final Response (Collapsed):**
```
┌──────────────────────────────────────────────────┐
│ ▸ Thought for 12s │
└──────────────────────────────────────────────────┘
```
**After Response Complete (Expandable):**
```
┌──────────────────────────────────────────────────┐
│ ▸ Thought for 12s [click to expand] │
└──────────────────────────────────────────────────┘
```
---
## Naming
| Term | Description |
| ------------------------- | -------------------------------------------- |
| **Agent Reasoning Block** | The full UI component |
| **Reasoning Steps** | Individual bullet points (1-2 per iteration) |
| **Reasoning Timer** | Elapsed seconds counter |
---
## Architecture
### State Machine
```
┌─────────────┐ tool call ┌─────────────┐
│ IDLE │ ────────────────▶ │ REASONING │
└─────────────┘ └─────────────┘
│ final response starts
┌─────────────┐
│ COLLAPSED │
└─────────────┘
│ response complete
┌─────────────┐
│ COMPLETE │
└─────────────┘
```
### Data Flow
```
AutonomousAgentChainRunner
├── onReasoningStart(timestamp)
│ └── Start timer, set state = REASONING
├── onReasoningStep(summary: string)
│ └── Add bullet point to steps array
├── onReasoningEnd()
│ └── Set state = COLLAPSED, stop timer
└── Final streaming via updateCurrentAiMessage
└── Normal text streaming (block stays collapsed)
```
---
## Implementation Phases
### Phase 1: Data Model & State
**File:** `src/LLMProviders/chainRunner/utils/AgentReasoningState.ts` (new)
```typescript
export interface ReasoningStep {
timestamp: number;
summary: string; // e.g., "Searching notes for 'AI'"
toolName?: string;
}
export interface AgentReasoningState {
status: "idle" | "reasoning" | "collapsed" | "complete";
startTime: number | null;
elapsedSeconds: number;
steps: ReasoningStep[];
}
export function createInitialReasoningState(): AgentReasoningState {
return {
status: "idle",
startTime: null,
elapsedSeconds: 0,
steps: [],
};
}
// Serialize to marker format (embedded in message)
export function serializeReasoningBlock(state: AgentReasoningState): string {
const data = {
elapsed: state.elapsedSeconds,
steps: state.steps.map((s) => s.summary),
};
return `<!--REASONING_BLOCK:${JSON.stringify(data)}-->`;
}
// Parse from marker format
export function parseReasoningBlock(marker: string): { elapsed: number; steps: string[] } | null {
const match = marker.match(/<!--REASONING_BLOCK:(.+?)-->/);
if (!match) return null;
try {
return JSON.parse(match[1]);
} catch {
return null;
}
}
```
### Phase 2: Update AutonomousAgentChainRunner
**File:** `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts`
**Changes:**
1. Add reasoning state tracking:
```typescript
private reasoningState: AgentReasoningState = createInitialReasoningState();
private reasoningTimerInterval: NodeJS.Timeout | null = null;
```
2. Add helper methods:
```typescript
private startReasoningTimer(updateFn: (message: string) => void): void {
this.reasoningState = {
status: 'reasoning',
startTime: Date.now(),
elapsedSeconds: 0,
steps: [],
};
// Update every 100ms for responsive timer
this.reasoningTimerInterval = setInterval(() => {
if (this.reasoningState.startTime) {
this.reasoningState.elapsedSeconds = Math.floor(
(Date.now() - this.reasoningState.startTime) / 1000
);
// Emit updated reasoning block
updateFn(this.buildReasoningBlockMarkup());
}
}, 100);
}
private addReasoningStep(summary: string): void {
// Keep only last 2 steps for concise display
this.reasoningState.steps.push({
timestamp: Date.now(),
summary,
});
if (this.reasoningState.steps.length > 2) {
this.reasoningState.steps.shift();
}
}
private stopReasoningTimer(): void {
if (this.reasoningTimerInterval) {
clearInterval(this.reasoningTimerInterval);
this.reasoningTimerInterval = null;
}
this.reasoningState.status = 'collapsed';
}
private buildReasoningBlockMarkup(): string {
const { status, elapsedSeconds, steps } = this.reasoningState;
if (status === 'idle') return '';
// Use special marker that ChatSingleMessage will parse
const stepsJson = JSON.stringify(steps.map(s => s.summary));
return `<!--AGENT_REASONING:${status}:${elapsedSeconds}:${stepsJson}-->`;
}
```
3. Integrate into agent loop:
```typescript
async run(...) {
// Start reasoning timer at beginning
this.startReasoningTimer(updateCurrentAiMessage);
try {
// ... agent loop ...
// When executing a tool:
this.addReasoningStep(`Calling ${toolName}...`);
updateCurrentAiMessage(this.buildReasoningBlockMarkup());
// After tool result:
this.addReasoningStep(this.summarizeToolResult(toolName, result));
updateCurrentAiMessage(this.buildReasoningBlockMarkup());
// When final response starts:
this.stopReasoningTimer();
const collapsedBlock = this.buildReasoningBlockMarkup();
// Stream final response AFTER the collapsed block
for await (const chunk of stream) {
updateCurrentAiMessage(collapsedBlock + chunk);
}
} finally {
this.stopReasoningTimer();
}
}
```
4. Add step summarization helper:
```typescript
private summarizeToolResult(toolName: string, result: any): string {
switch (toolName) {
case 'localSearch':
const count = result?.documents?.length || 0;
return `Found ${count} relevant note${count !== 1 ? 's' : ''}`;
case 'webSearch':
return 'Retrieved web search results';
case 'getTimeRangeMs':
return 'Calculated time range';
default:
return `Completed ${toolName}`;
}
}
```
### Phase 3: React Component
**File:** `src/components/chat-components/AgentReasoningBlock.tsx` (new)
```tsx
import React, { useState, useEffect, useRef } from "react";
interface AgentReasoningBlockProps {
status: "reasoning" | "collapsed" | "complete";
elapsedSeconds: number;
steps: string[];
isStreaming: boolean;
}
export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
status,
elapsedSeconds,
steps,
isStreaming,
}) => {
const [isExpanded, setIsExpanded] = useState(status === "reasoning");
// Auto-collapse when status changes to collapsed
useEffect(() => {
if (status === "collapsed" || status === "complete") {
setIsExpanded(false);
} else if (status === "reasoning") {
setIsExpanded(true);
}
}, [status]);
const formatTime = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs}s`;
};
const isActive = status === "reasoning";
return (
<div className="agent-reasoning-block">
{/* Header - always visible */}
<div
className="agent-reasoning-header"
onClick={() => !isActive && setIsExpanded(!isExpanded)}
style={{ cursor: isActive ? "default" : "pointer" }}
>
{/* Spinner or expand chevron */}
<span className="agent-reasoning-icon">
{isActive ? (
<LoadingSpinner />
) : (
<span className={`chevron ${isExpanded ? "expanded" : ""}`}></span>
)}
</span>
{/* Title and timer */}
<span className="agent-reasoning-title">{isActive ? "Reasoning" : "Thought for"}</span>
<span className="agent-reasoning-timer">{formatTime(elapsedSeconds)}</span>
</div>
{/* Steps - visible when expanded */}
{isExpanded && steps.length > 0 && (
<ul className="agent-reasoning-steps">
{steps.map((step, i) => (
<li key={i} className="agent-reasoning-step">
{step}
</li>
))}
</ul>
)}
</div>
);
};
const LoadingSpinner: React.FC = () => (
<span className="agent-reasoning-spinner">
{/* 6-dot braille pattern spinner */}
<span className="spinner-dots"></span>
</span>
);
```
### Phase 4: CSS Styling
**File:** `src/styles/tailwind.css` (append to existing)
```css
/* Agent Reasoning Block */
.agent-reasoning-block {
margin: 8px 0;
padding: 12px 16px;
border-radius: var(--radius-m);
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
font-size: var(--font-ui-small);
}
.agent-reasoning-header {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
}
.agent-reasoning-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
}
.agent-reasoning-icon .chevron {
transition: transform 0.15s ease;
font-size: 10px;
}
.agent-reasoning-icon .chevron.expanded {
transform: rotate(90deg);
}
.agent-reasoning-title {
font-weight: var(--font-medium);
}
.agent-reasoning-timer {
color: var(--text-faint);
}
.agent-reasoning-steps {
margin: 8px 0 0 24px;
padding: 0;
list-style: disc;
}
.agent-reasoning-step {
margin: 4px 0;
color: var(--text-normal);
line-height: 1.4;
}
/* Spinner animation */
.agent-reasoning-spinner .spinner-dots {
display: inline-block;
animation: reasoning-pulse 1s ease-in-out infinite;
}
@keyframes reasoning-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
```
### Phase 5: Message Rendering Integration
**File:** `src/components/chat-components/ChatSingleMessage.tsx`
**Changes:**
1. Add parsing for reasoning block marker:
```typescript
function parseAgentReasoningMarker(content: string): {
hasReasoning: boolean;
status: "reasoning" | "collapsed" | "complete";
elapsedSeconds: number;
steps: string[];
contentAfter: string;
} | null {
const match = content.match(/<!--AGENT_REASONING:(\w+):(\d+):(.+?)-->/);
if (!match) return null;
const [fullMatch, status, elapsed, stepsJson] = match;
const steps = JSON.parse(stepsJson) as string[];
return {
hasReasoning: true,
status: status as "reasoning" | "collapsed" | "complete",
elapsedSeconds: parseInt(elapsed, 10),
steps,
contentAfter: content.replace(fullMatch, "").trim(),
};
}
```
2. Update render logic:
```typescript
// In ChatSingleMessage render:
const reasoningData = parseAgentReasoningMarker(message.content);
return (
<div className="chat-message">
{/* Agent Reasoning Block (if present) */}
{reasoningData?.hasReasoning && (
<AgentReasoningBlock
status={reasoningData.status}
elapsedSeconds={reasoningData.elapsedSeconds}
steps={reasoningData.steps}
isStreaming={isStreaming}
/>
)}
{/* Message content (after reasoning marker) */}
<div className="message-content">
{/* Render remainingContent via markdown */}
</div>
</div>
);
```
### Phase 6: Remove Old Tool Call Banner
**Files to modify:**
1. `src/components/chat-components/ToolCallBanner.tsx` - Delete or deprecate
2. `src/components/chat-components/toolCallRootManager.tsx` - Simplify or remove
3. `src/LLMProviders/chainRunner/utils/toolCallParser.ts` - Keep for backward compat with saved messages
4. `src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.ts` - Remove tool call marker handling
**Deprecation strategy:**
- Keep `parseToolCallMarkers()` for rendering old saved messages
- Remove `createToolCallMarker()` and `updateToolCallMarker()`
- Don't create new tool call markers in agent runner
### Phase 7: Disable Thinking Block in Agent Mode
**File:** `src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.ts`
Add flag to skip thinking content extraction in agent mode:
```typescript
export class ThinkBlockStreamer {
private suppressThinkingContent: boolean;
constructor(
updateFn: (message: string) => void,
options?: { suppressThinkingContent?: boolean }
) {
this.updateFn = updateFn;
this.suppressThinkingContent = options?.suppressThinkingContent ?? false;
}
processChunk(chunk: any): void {
if (this.suppressThinkingContent) {
// Skip thinking content, only extract text
// ... simplified logic ...
} else {
// ... existing thinking block logic ...
}
}
}
```
In AutonomousAgentChainRunner:
```typescript
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage, {
suppressThinkingContent: true, // Agent mode uses AgentReasoningBlock instead
});
```
---
## Performance Considerations
1. **Timer Updates**: Use 100ms interval for responsive feel without excessive re-renders
2. **Step Limit**: Keep only last 2 steps to prevent DOM bloat
3. **Marker Format**: Compact JSON for minimal message overhead
4. **React Roots**: Use same pattern as tool call banner for persistent roots
---
## Migration Path
1. **Phase 1**: Add new AgentReasoningBlock alongside existing tool call banner
2. **Phase 2**: Test with agent mode, ensure backward compat with old messages
3. **Phase 3**: Remove tool call banner creation code (keep parsing)
4. **Phase 4**: Clean up deprecated code after stable release
---
## Testing Checklist
- [ ] Timer updates smoothly (no flicker)
- [ ] Steps appear as tools execute
- [ ] Block collapses when final response starts
- [ ] Collapsed block shows correct elapsed time
- [ ] Click to expand works after response complete
- [ ] Old messages with tool call banners still render
- [ ] No thinking blocks appear in agent mode
- [ ] Performance: no lag with multiple iterations
---
## File Summary
| File | Action |
| ------------------------------- | ------------------------------------- |
| `AgentReasoningState.ts` | **New** - State management |
| `AgentReasoningBlock.tsx` | **New** - React component |
| `AutonomousAgentChainRunner.ts` | **Modify** - Add reasoning tracking |
| `ChatSingleMessage.tsx` | **Modify** - Render reasoning block |
| `ThinkBlockStreamer.ts` | **Modify** - Add suppress option |
| `tailwind.css` | **Modify** - Add styles |
| `ToolCallBanner.tsx` | **Deprecate** - Keep for old messages |
| `toolCallParser.ts` | **Keep** - Backward compat only |

View file

@ -0,0 +1,152 @@
# TODO: Composer Tool Redesign for Faster Feedback
**Date:** 2026-02-04
**Assignee:** @wenzhengjiang
**Status:** TODO
## Problem
The `writeToFile` and `replaceInFile` composer tools have a significant UX issue: there's a **30+ second gap** between the initial reasoning step and any meaningful feedback about what the agent intends to do.
### Root Cause
The current flow requires the model to generate the **entire modified file content** in a single tool call:
```
User: "Remove all the headings"
Model receives prompt + full file content
Model generates ENTIRE modified file (30+ seconds)
Tool call emitted with full content
UI finally shows "Writing to filename..."
```
During those 30+ seconds, the user has no idea what the agent is planning to do.
### Current Tool Schema
```typescript
writeToFile({
path: string,
content: string | object, // ← ENTIRE file content generated upfront
confirmation: boolean,
});
```
## Proposed Solution: Multi-Step Tool Design
Split the composer operation into two phases:
### Phase 1: Intent Declaration (Fast)
A lightweight tool call that declares intent without generating content:
```typescript
declareEditIntent({
path: string,
operation: "rewrite" | "modify" | "create",
description: string, // e.g., "Remove all headings from the document"
});
```
This would return almost immediately (1-2 seconds) because the model only needs to decide WHAT to do, not generate the full content.
**UI shows:** "Planning to remove all headings from Daily-AI-Digest.md..."
### Phase 2: Content Generation (Slow but expected)
After intent is confirmed, the full content generation happens:
```typescript
executeEdit({
path: string,
content: string | object,
});
```
**UI shows:** "Generating changes..." → "Writing to filename..."
### Benefits
1. **Immediate feedback**: User knows the intent within 1-2 seconds
2. **Opportunity to cancel**: User can abort before expensive generation
3. **Better UX**: Progress feels natural (planning → executing)
4. **Clearer reasoning steps**: Each phase has distinct, meaningful steps
## Additional Requirements
### Diff History Cache for Reliable Revert
Cache the last N diffs per file to enable reliable undo/revert functionality:
- Store diffs (not full file snapshots) to minimize storage
- Keep last N changes (e.g., N=10) per file path
- Enable "Revert last change" and "Revert to version X" actions
- Clear old diffs when limit exceeded (FIFO)
```typescript
interface DiffHistoryEntry {
timestamp: number;
path: string;
diff: Change[]; // from 'diff' library
description: string; // e.g., "Remove all headings"
}
```
### Dedicated "Apply" Model
Consider using a separate small, fast, and cheap model specifically for applying diffs:
- **Main model**: Generates the intent and edit description (Phase 1)
- **Apply model**: Executes the actual file transformation (Phase 2)
Benefits:
- Faster execution for Phase 2 (smaller model = faster inference)
- Lower cost (cheap model for mechanical transformation)
- Main model focuses on understanding, apply model focuses on execution
- Could use a fine-tuned model optimized for code/text transformations
Candidates: Gemini Flash Lite or comparable models.
### Structured Tool Results
Currently, ComposerTools returns plain strings for errors/no-ops, making result parsing fragile:
```typescript
// Current: Plain strings (fragile)
return `File is too small to use this tool...`;
return `Search text not found in file ${path}...`;
return `No changes made to ${path}...`;
```
The reasoning UI (`AgentReasoningState.ts`) uses string matching to detect these cases, which breaks if messages change.
**Proposed:** Return structured results:
```typescript
interface ComposerToolResult {
status: "success" | "rejected" | "no-op" | "error";
message: string;
path?: string;
}
```
This enables reliable status detection in the reasoning UI without fragile string matching.
## Related Files
- `src/tools/ComposerTools.ts` - Current tool implementations
- `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts` - ReAct loop
- `src/LLMProviders/chainRunner/utils/AgentReasoningState.ts` - Reasoning UI state
## Next Steps
1. Design the multi-step tool API
2. Prototype with `writeToFile` first
3. Update reasoning UI to handle two-phase operations
4. Test with various file sizes and operations
5. Roll out to `replaceInFile`

View file

@ -0,0 +1,311 @@
# Token Budget Enforcement
## Table of Contents
1. [Problem Statement](#problem-statement)
2. [Current Compaction Architecture](#current-compaction-architecture)
3. [Root Cause Analysis](#root-cause-analysis)
4. [Fix Plan](#fix-plan)
5. [References](#references)
---
## Problem Statement
The model proxy receives requests with token counts far exceeding the model's context window (e.g., 2.7M tokens sent to a 1M-token Vertex AI model). The plugin's auto-compaction system was expected to prevent this but fails because **no compaction mechanism checks the total assembled payload** — each compactor guards only its own subset.
```
ContextWindowExceededError: The input token count (2769478)
exceeds the maximum number of tokens allowed (1048575).
```
---
## Current Compaction Architecture
There are **three separate compaction mechanisms** in the plugin. None of them enforce a total token budget against the `autoCompactThreshold` setting.
### 1. Turn-Time Context Compaction (ContextCompactor)
**Where**: `ContextManager.processMessageContext()` (`src/core/ContextManager.ts:231-258`)
**When**: Every time a user message is processed, before the envelope is built.
**What it covers**: L2 (previous turn context) + L3 (current turn context) combined.
**What it does NOT cover**: L1 (system prompt), L4 (chat history), L5 (user message).
```
Trigger condition:
(processedUserMessage + contextPortion).length > autoCompactThreshold * 4
Where:
autoCompactThreshold = settings.autoCompactThreshold (default: 128,000 tokens)
charThreshold = 128,000 * 4 = 512,000 chars
```
When triggered, `ContextCompactor.compact()` performs map-reduce LLM summarization on individual XML blocks larger than 50k chars. The user message itself is never compacted.
**Key limitation**: This threshold check measures `processedUserMessage + contextPortion` (which is L5 + L2 + L3). It does NOT include:
- L1 (system prompt) — typically 2-10k tokens
- L4 (chat history) — potentially **hundreds of thousands of tokens**
### 2. L2 Carry-Forward Compaction (L2ContextCompactor)
**Where**: `ContextManager.compactSegmentForL2()` (`src/core/ContextManager.ts:706-733`)
**When**: When previous turn L3 segments are promoted into L2 for the next turn.
**What it does**: Deterministic structure+preview compression (headings + truncated sections). No LLM calls.
This is a **per-segment** operation that reduces each context artifact to a `<prior_context>` block with ~500 chars per section. This prevents L2 from growing unbounded as turns accumulate.
### 3. Chat History Compaction (ChatHistoryCompactor)
**Where**: `MemoryManager.saveContext()` (`src/LLMProviders/memoryManager.ts:61-72`)
**When**: After each assistant response, at memory save time.
**What it does**: Compacts tool results (`localSearch`, `readNote`, etc.) in assistant responses before saving to `BufferWindowMemory`.
This compacts **only the tool-result portions** of assistant messages. The rest of the assistant text and all user messages are stored verbatim.
### Summary: What Each System Protects
| Compaction System | Scope | Token-Aware? | Covers Full Payload? |
| ---------------------------------- | ---------------------------------- | ------------------------------- | ---------------------- |
| ContextCompactor (turn-time) | L2 + L3 context XML blocks | Threshold-based (char estimate) | No — misses L1, L4, L5 |
| L2ContextCompactor (carry-forward) | Individual L2 segments | No — fixed per-segment | No — per-segment only |
| ChatHistoryCompactor (save-time) | Tool results in assistant messages | No — fixed size | No — only tool results |
---
## Root Cause Analysis
### The Core Problem: No Total Payload Budget
The critical gap is **systemic**: no compaction mechanism checks the total assembled payload (L1+L2+L3+L4+L5) against any budget. Each compactor guards only its own subset, and no final safety net exists.
### What L4 Actually Contains
L4 (chat history) is often assumed to be the main token consumer, but investigation shows it is relatively well-controlled:
- **User messages in L4** = bare L5 text only (no context XML). `BaseChainRunner.handleResponse()` extracts `l5Text` from the envelope and saves only that to memory.
- **Assistant responses in L4** = compacted at save time by `ChatHistoryCompactor`, which strips tool result XML (`localSearch`, `readNote`, `note_context`, etc.).
- **Agent-mode responses**: `AutonomousAgentChainRunner` saves only `loopResult.finalResponse` (the final answer), NOT the full reasoning/tool-call chain.
L4 does grow with conversation length, but it is not unbounded — `BufferWindowMemory` limits it to `k = contextTurns * 2` messages (default: 30), and both user and assistant sides are relatively compact.
### The Real Culprits: L1 and Unchecked Layer Accumulation
The overflow happens because **multiple layers accumulate without any shared budget**:
#### L1: Project Context Is Never Budgeted
In Projects mode, `ChatManager.getSystemPromptForMessage()` concatenates all project files, web content, and YouTube transcripts into a `<project_context>` block inside L1. This can easily reach **hundreds of thousands of tokens** for large projects.
L1 is **never compacted by any system** — no compactor even sees it.
#### Compaction Threshold Is Blind to L1
`ContextManager.processMessageContext()` uses a hardcoded `PROJECT_COMPACT_THRESHOLD = 1,000,000` tokens for Projects mode compaction. This threshold checks only L2+L3 size — it is completely blind to L1 (project context) size. It is set as if L2+L3 is the _entire_ budget, when in reality L1 may have already consumed most of the available context window.
For non-project chains, `autoCompactThreshold` (default 128k) is used, but it also only checks L2+L3.
#### L4: No Budget Awareness
`loadAndAddChatHistory()` loads all history messages without checking how much token budget remains after L1+L2+L3+L5 are assembled:
```typescript
export async function loadAndAddChatHistory(
memory: any,
messages: Array<{ role: string; content: any }>
): Promise<ProcessedMessage[]> {
const memoryVariables = await memory.loadMemoryVariables({});
const rawHistory = memoryVariables.history || [];
// ... processes and adds ALL history messages with NO size check
}
```
### How 2.7M Tokens Happen
In a Projects-mode conversation:
```
L1 (system + project_context): ~500k tokens ← UNBUDGETED, never compacted
L2 (previous context, compacted): ~20k tokens
L3 (current turn context): ~50k tokens
─────────
ContextCompactor checks L2+L3: 70k < 1,000k threshold NO compaction triggered
(threshold is blind to 500k in L1)
L4 (15 turns of chat history): ~200k tokens ← loaded with no remaining budget check
L5 (user message): ~2k tokens
─────────────────────────────────────────────────
TOTAL: ~772k tokens → may exceed model's context window
```
In extreme cases (large projects + long conversations + heavy context attachments), totals can reach 2M+ tokens.
### All Chain Runners Are Affected
All chain runners call `loadAndAddChatHistory()` without any token budget:
| Runner | File | Line |
| -------------------------- | ------------------------------------------------------------ | ---- |
| LLMChainRunner | `src/LLMProviders/chainRunner/LLMChainRunner.ts` | 45 |
| CopilotPlusChainRunner | `src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts` | 606 |
| AutonomousAgentChainRunner | `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts` | 597 |
| VaultQAChainRunner | `src/LLMProviders/chainRunner/VaultQAChainRunner.ts` | 191 |
### The `contextTurns` Setting Is a Poor Proxy
`BufferWindowMemory` is configured with `k = contextTurns * 2` (default: 30 messages). This is a crude count-based limit that:
- Has no relation to actual token consumption
- Cannot adapt to varying message sizes
- Provides no guarantees about total payload size
A token-based budget for L4 makes `contextTurns` redundant.
---
## Fix Plan
### Guiding Principles
1. **Model-agnostic**: The plugin supports many LLM providers. No model-specific context window logic. Use `autoCompactThreshold` (user-configurable) as the single total budget.
2. **Single enforcement point**: Token budget must be checked where all layers are assembled, not scattered across individual compactors.
3. **History guarantee**: The LLM must always see at least some recent chat history to resume conversation context, even when L1+L2+L3 consume most of the budget.
4. **Graceful degradation**: When over budget, drop the least-valuable content first (oldest history turns), then compact further if needed.
5. **Backwards compatible**: Existing compaction systems remain; this adds a final safety net.
6. **No LLM calls in the hot path**: Budget enforcement should use fast char-based estimation (chars / 4), not LLM summarization.
### Phase 1: Token Budget Guard (Critical Fix)
**Goal**: Prevent over-budget payloads from ever reaching the LLM.
#### 1.1 Make ContextManager L1-Aware
Currently `ContextManager.processMessageContext()` checks `(L2+L3).length > threshold * 4` where threshold is either `autoCompactThreshold` or `PROJECT_COMPACT_THRESHOLD`. Both are blind to L1 size.
**Fix**: The compaction threshold for L2+L3 must account for L1:
```
effectiveThreshold = autoCompactThreshold - estimateTokens(L1)
```
This ensures that when L1 is large (e.g., Projects mode with many files), L2+L3 compaction triggers earlier, leaving room for L4 and L5.
**Kill `PROJECT_COMPACT_THRESHOLD`** — it is a hardcoded 1M value that pretends L1 doesn't exist. Replace with the same `autoCompactThreshold - L1` formula for all chain types.
**File**: `src/core/ContextManager.ts`
#### 1.2 Add Token Budget to `loadAndAddChatHistory()`
Add an optional `tokenBudget` parameter to `loadAndAddChatHistory()`. When provided:
1. Load all history messages from `BufferWindowMemory`
2. Estimate token count of each message (chars / 4)
3. Drop oldest complete turns (user+assistant pairs) until cumulative total fits within budget
4. Always keep at least the most recent turn (history guarantee)
5. Log a warning when turns are dropped
```
Token Budget Allocation:
autoCompactThreshold (e.g., 128,000 tokens)
- estimateTokens(L1) system prompt + project context
- estimateTokens(L2) previous context library
- estimateTokens(L3) current turn context
- estimateTokens(L5) user message
- reservedForOutput (~4,096 for response generation)
= remaining budget for L4 chat history
```
**File**: `src/LLMProviders/chainRunner/utils/chatHistoryUtils.ts`
#### 1.3 Update All Chain Runners
Each chain runner calls `loadAndAddChatHistory()`. Update call sites to:
1. Calculate the token size of already-assembled non-L4 messages (L1+L2+L3+L5)
2. Compute `historyBudget = autoCompactThreshold - nonL4Tokens - outputReserve`
3. Pass `historyBudget` to `loadAndAddChatHistory()`
**Files**:
- `src/LLMProviders/chainRunner/LLMChainRunner.ts`
- `src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts`
- `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts`
- `src/LLMProviders/chainRunner/VaultQAChainRunner.ts`
#### 1.4 Deprecate `contextTurns` Setting
Token-based budget trimming makes the count-based `contextTurns` setting redundant.
- Replace `BufferWindowMemory.k = contextTurns * 2` with a generous internal constant (e.g., `k = 100`)
- The token budget in step 1.2 handles the actual trimming
- Remove the "Conversation turns in context" slider from `ModelSettings.tsx`
**Files**:
- `src/LLMProviders/memoryManager.ts`
- `src/settings/v2/components/ModelSettings.tsx`
### Phase 2: Smarter History Trimming (Enhancement)
**Goal**: When budget is tight, trim intelligently rather than just dropping oldest turns.
#### 2.1 Prioritized trimming strategy
When over budget, apply in order:
1. **Drop oldest complete turns** (user+assistant pairs) from L4
2. **Truncate remaining long assistant responses** in L4 (keep first N chars)
3. If _still_ over budget after L4 is minimized, **warn user** and proceed — the LLM will still see the most recent turn
### Phase 3: Observability (Enhancement)
#### 3.1 Surface token usage to UI
Add a debug/info display showing:
- Estimated tokens per layer (L1, L2, L3, L4, L5)
- Total vs. `autoCompactThreshold`
- Whether any history turns were dropped
This helps users understand why responses might miss context from earlier turns.
### Implementation Order
| Step | Description | Files Changed | Risk |
| ---- | ----------------------------------------- | ------------------------------- | ------ |
| 1.1 | L1-aware compaction threshold | ContextManager.ts | Medium |
| 1.2 | Token budget in `loadAndAddChatHistory()` | chatHistoryUtils.ts | Medium |
| 1.3 | Update chain runner call sites | 4 chain runner files | Medium |
| 1.4 | Deprecate `contextTurns` | memoryManager.ts, ModelSettings | Low |
| 2.1 | Prioritized trimming | chatHistoryUtils.ts | Low |
| 3.1 | Token usage debug display | UI components | Low |
Phase 1 (steps 1.1-1.4) is the **critical fix** that prevents the overflow. Phases 2-3 are improvements.
---
## References
### Source Files
| File | Role |
| ------------------------------------------------------------ | ----------------------------------------------- |
| `src/core/ContextManager.ts` | Turn-time compaction trigger (L2+L3) |
| `src/core/ContextCompactor.ts` | Map-reduce LLM summarization |
| `src/context/L2ContextCompactor.ts` | Deterministic L2 segment compaction |
| `src/context/ChatHistoryCompactor.ts` | Tool result compaction at save time |
| `src/LLMProviders/memoryManager.ts` | Memory save with compaction |
| `src/LLMProviders/chainRunner/utils/chatHistoryUtils.ts` | Chat history loading (no budget) |
| `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts` | Agent message assembly |
| `src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts` | CopilotPlus message assembly |
| `src/LLMProviders/chainRunner/LLMChainRunner.ts` | Basic LLM message assembly |
| `src/LLMProviders/chainRunner/VaultQAChainRunner.ts` | VaultQA message assembly |
| `src/LLMProviders/chatModelManager.ts` | Chat model management |
| `src/constants.ts` | Default settings (autoCompactThreshold: 128000) |
### Related Docs
- [CONTEXT_ENGINEERING.md](./CONTEXT_ENGINEERING.md) — L1-L5 layer architecture
- [MESSAGE_ARCHITECTURE.md](./MESSAGE_ARCHITECTURE.md) — Message flow and storage
- [TECHDEBT.md](./TECHDEBT.md) — Known technical debt

View file

@ -0,0 +1,446 @@
# TODO - UI Rendering Performance Issues
This document tracks UI rendering performance issues identified through a comprehensive audit of the React component tree, state management, and streaming paths. Findings are ranked by severity and organized by recommended fix priority.
## Severity Legend
- **CRITICAL** - Causes visible jank/stalls during normal usage; affects every user
- **HIGH** - Causes noticeable stalls in specific scenarios or degrades with scale
- **MEDIUM** - Contributes to cumulative performance degradation
- **LOW** - Minor inefficiency; fix opportunistically
---
## 1. [TODO] ChatSingleMessage Not Memoized — Streaming Re-renders All Historical Messages (CRITICAL)
### Issue Description
`ChatSingleMessage` is the most expensive component in the application yet is not wrapped in `React.memo`. Every streaming token update causes ALL historical messages to re-render with full MarkdownRenderer passes and DOM manipulation.
### Technical Details
- **Files**: `src/components/chat-components/ChatSingleMessage.tsx`, `src/components/chat-components/ChatMessages.tsx:88-115`
- **Root Cause**: When `ChatMessages` (which IS memoized) re-renders due to `currentAiMessage` changing during streaming, the `.map()` at line 88 creates new React elements for ALL historical messages. Each gets new inline closure props:
- `() => onRegenerate(index)` (line 108)
- `(newMessage) => onEdit(index, newMessage)` (line 109)
- `() => onDelete(index)` (line 110)
- These inline closures create new function references on every render, which would defeat `React.memo` even if it were added without also stabilizing the callbacks.
- `ChatSingleMessage` contains: `MarkdownRenderer.renderMarkdown()`, DOM manipulation (`querySelectorAll`, `createElement`, `insertBefore`), `parseToolCallMarkers()`, multiple regex passes, and multiple `useEffect` hooks.
### Recommended Solution
1. Wrap `ChatSingleMessage` in `React.memo` with a custom comparator that checks `message.id`, `message.message`, `isStreaming`, and callback identity.
2. Replace inline closure callbacks in `ChatMessages.map()` with stable references. Options:
- Pass `messageIndex` as a prop and let `ChatSingleMessage` call `onRegenerate(messageIndex)` internally.
- Use `useCallback` with a ref-based pattern to avoid `chatHistory` dependency (see Finding #9).
### Impact
- **Affects**: Every LLM response stream for every user.
- **Severity scales with**: Conversation length. A 20-message conversation means 20 unnecessary expensive re-renders per animation frame during streaming.
- **Expected improvement**: Eliminating historical message re-renders during streaming would be the single largest performance win in the codebase.
---
## 2. [TODO] O(N^2) Filter Inside .map() in ChatMessages (CRITICAL)
### Issue Description
`chatHistory.filter()` is called inside the `.map()` callback on every iteration, creating O(N^2) complexity per render.
### Technical Details
- **File**: `src/components/chat-components/ChatMessages.tsx:89`
- **Code**: `const visibleMessages = chatHistory.filter((m) => m.isVisible);` is called inside `.map()` to compute `isLastMessage`. For N messages, this executes N filter operations of O(N) each = O(N^2).
- Combined with Finding #1 (re-renders on every streaming frame), this compounds badly.
### Recommended Solution
Hoist the filter before the `.map()`:
```tsx
const visibleMessages = useMemo(() => chatHistory.filter((m) => m.isVisible), [chatHistory]);
// Then use visibleMessages.length inside .map()
```
### Impact
- **Affects**: Every render during streaming.
- **For 100 messages**: 10,000 filter operations per render frame.
- **Fix effort**: Trivial — single-line hoist.
---
## 3. [TODO] ChatManager.getCurrentMessageRepo() Creates New ChatPersistenceManager on Every Call (HIGH)
### Issue Description
A new `ChatPersistenceManager` object is allocated on every call to `getCurrentMessageRepo()`, which is invoked by virtually every read/write operation.
### Technical Details
- **File**: `src/core/ChatManager.ts:82-87`
- **Code**: `this.persistenceManager = new ChatPersistenceManager(this.plugin.app, currentRepo, this.chainManager)` runs unconditionally inside `getCurrentMessageRepo()`.
- This method is called by `getDisplayMessages()`, `getLLMMessages()`, `getMessage()`, `addMessage()`, `deleteMessage()`, etc.
- Via `useChatManager`, `getDisplayMessages()` is called on every subscription notification from `ChatUIState`.
### Recommended Solution
Cache the `ChatPersistenceManager` per project key. Only recreate when the project actually changes:
```typescript
if (!this.persistenceManagers.has(projectKey)) {
this.persistenceManagers.set(projectKey, new ChatPersistenceManager(...));
}
```
### Impact
- **Affects**: Every message operation (read or write).
- **Causes**: Unnecessary object allocation and GC pressure on every render cycle.
---
## 4. [TODO] useChatManager Creates New Array Reference on Every State Notification (HIGH)
### Issue Description
`useChatManager` always spreads into a new array, meaning React always sees a new `messages` reference, defeating downstream memoization.
### Technical Details
- **File**: `src/hooks/useChatManager.ts:21`
- **Code**: `setMessages([...chatUIState.getMessages()])` — the spread operator always creates a new array reference regardless of whether the content has changed.
- Every `notifyListeners()` call (from any message operation) triggers this, causing `ChatMessages` to re-render even if the actual messages haven't changed.
### Recommended Solution
Use structural comparison or a version counter to avoid unnecessary state updates:
```typescript
const unsubscribe = chatUIState.subscribe(() => {
const next = chatUIState.getMessages();
setMessages((prev) => {
// Only update if messages actually changed
if (
prev.length === next.length &&
prev.every((m, i) => m.id === next[i].id && m.message === next[i].message)
) {
return prev;
}
return [...next];
});
});
```
Alternatively, add a version/generation counter to `MessageRepository` and only spread when the version changes.
### Impact
- **Affects**: Every state change cascades into unnecessary `ChatMessages` re-renders.
- **Compounds with**: Finding #1 (unmemoized ChatSingleMessage) and Finding #9 (unstable callback deps).
---
## 5. [TODO] useChatScrolling Triggers Expensive DOM Queries on Every chatHistory Change (HIGH)
### Issue Description
`calculateDynamicMinHeight` performs DOM queries (`querySelector`, `getBoundingClientRect`) and is called on every `chatHistory` change, causing layout thrashing during streaming.
### Technical Details
- **File**: `src/hooks/useChatScrolling.ts:29-66, 109-114`
- `calculateDynamicMinHeight` has `chatHistory` in its dependency array, so it changes identity on every message update.
- The `useEffect` at line 109 calls it on every `chatHistory` change.
- It does `querySelector` + `getBoundingClientRect`, which forces browser layout recalculation (reflow).
- Since `chatHistory` gets a new reference frequently (Finding #4), this triggers expensive layout recalculations very often.
### Recommended Solution
1. Debounce or throttle `calculateDynamicMinHeight` calls (e.g., only recalculate on user message additions, not during streaming).
2. Decouple from `chatHistory` array identity — use `chatHistory.length` or a message count instead.
3. Consider using `ResizeObserver` on the last message element rather than querying on every state change.
### Impact
- **Affects**: Every message update during streaming.
- **Causes**: Layout thrashing (forced reflows) on the main thread.
---
## 6. [TODO] useAllNotes Sorts Entire File List on Every Vault Change (HIGH)
### Issue Description
The `useAllNotes` hook sorts all vault files by creation date inside `useMemo`, triggered on every debounced vault event.
### Technical Details
- **File**: `src/components/chat-components/hooks/useAllNotes.ts:36`
- **Code**: `files.sort((a, b) => b.stat.ctime - a.stat.ctime)` runs inside `useMemo` with `[allNotes, isCopilotPlus]` deps.
- `allNotes` atom gets a new array reference on every debounced vault event (`VaultDataManager.refreshNotes` at `vaultDataAtoms.ts:214` always sets a new array).
- For vaults with 5000+ files, this is O(N log N) on every file create/delete/rename.
### Recommended Solution
Move sorting into `VaultDataManager.refreshNotes()` so it happens once at the source, not in every consumer. Or pre-sort the atom value.
### Impact
- **Affects**: Users with large vaults (5000+ files).
- **Triggers**: On every file create/delete/rename in the vault (debounced at 250ms).
---
## 7. [TODO] Loading Dots Animation Triggers Full ChatMessages Re-render Every 200ms (MEDIUM)
### Issue Description
The loading dots animation uses internal state (`setLoadingDots`) that triggers `ChatMessages` re-renders every 200ms, which cascades to all child message components.
### Technical Details
- **File**: `src/components/chat-components/ChatMessages.tsx:49-59`
- A `setInterval` at 200ms calls `setLoadingDots()`, updating internal state of the `memo`-wrapped `ChatMessages`.
- Internal state changes bypass `React.memo`, causing the entire message list to re-render.
- Combined with Finding #1, all historical `ChatSingleMessage` children re-render too.
### Recommended Solution
Extract the loading dots into a separate small component that manages its own state:
```tsx
const LoadingDots: React.FC = () => {
const [dots, setDots] = useState("");
useEffect(() => {
/* interval logic */
}, []);
return <span>{dots}</span>;
};
```
This isolates the 200ms re-renders to just the loading indicator, not the entire message list.
### Impact
- **Affects**: Every loading phase (waiting for AI response).
- **Causes**: 5 unnecessary full-tree re-renders per second during loading.
---
## 8. [TODO] VaultDataManager Tag Refresh Scans All Markdown Files (MEDIUM)
### Issue Description
`refreshTagsFrontmatter()` and `refreshTagsAll()` each iterate over ALL markdown files, and both are triggered independently on every file modify/metadata change.
### Technical Details
- **File**: `src/state/vaultDataAtoms.ts:234-271`
- Both methods call `app.vault.getMarkdownFiles()` and iterate with `getTagsFromNote()` on each file.
- Both are triggered by `handleFileModify` and `handleMetadataChange` events (debounced at 250ms).
- For a vault with 5000 markdown files, this means two full vault scans on every file save.
### Recommended Solution
1. Merge the two refresh methods into a single pass that computes both frontmatter and all tags simultaneously.
2. Consider incremental tag updates — only recompute tags for the changed file, not the entire vault.
### Impact
- **Affects**: Users with large vaults.
- **Triggers**: On every file save (after 250ms debounce).
- **Usually mitigated by**: Debouncing. But for very large vaults, even one scan can take 50-100ms.
---
## 9. [TODO] Chat.tsx Callback Dependencies Include chatHistory Array (MEDIUM)
### Issue Description
`handleRegenerate`, `handleEdit`, and `handleDelete` in `Chat.tsx` all depend on `chatHistory` in their `useCallback` dependency arrays, causing them to be recreated on every render and defeating `ChatMessages`'s `React.memo`.
### Technical Details
- **File**: `src/components/Chat.tsx:421-472, 474-550, 664-683`
- These callbacks access `chatHistory[messageIndex]` to get the message to operate on.
- Since `chatHistory` gets a new array reference on every state update (Finding #4), these callbacks are recreated on every render.
- They're passed as props to `ChatMessages` (which is memoized), but new callback references trigger re-renders regardless.
### Recommended Solution
Use a ref to hold the latest `chatHistory` and access it inside the callbacks:
```typescript
const chatHistoryRef = useRef(chatHistory);
chatHistoryRef.current = chatHistory;
const handleRegenerate = useCallback(
(messageIndex: number) => {
const message = chatHistoryRef.current[messageIndex];
// ... rest of logic
},
[
/* stable deps only */
]
);
```
This keeps the callback identity stable while always reading the latest data.
### Impact
- **Affects**: Effectively defeats the `React.memo` on `ChatMessages`, compounding with Finding #1.
---
## 10. [TODO] ChatSingleMessage DOM Manipulation in useEffect During Streaming (MEDIUM)
### Issue Description
The main rendering `useEffect` in `ChatSingleMessage` performs extensive synchronous DOM operations on every `message` prop change, which during streaming happens on every RAF tick.
### Technical Details
- **File**: `src/components/chat-components/ChatSingleMessage.tsx:533-723`
- Operations include: `querySelectorAll`, `createElement`, `insertBefore`, `appendChild`, `remove`, `MarkdownRenderer.renderMarkdown()`.
- During streaming, only the streaming `ChatSingleMessage` instance does this work (historical messages would too, per Finding #1, but they should not be receiving new props).
- The `preprocess` callback (line 246) runs multiple regex replacements and string splitting on every update.
### Recommended Solution
1. Fix Finding #1 first — this eliminates DOM manipulation for historical messages during streaming.
2. For the streaming message, consider differential updates (only re-render new content appended since last frame) rather than re-processing the entire message on every token.
### Impact
- **Affects**: Streaming message rendering.
- **Mostly contained**: After fixing Finding #1, only one component instance does this work per frame.
---
## 11. [TODO] ChatSingleMessage preprocess: Repeated Regex Splitting (MEDIUM)
### Issue Description
The `replaceLinks` helper splits the message by code blocks using a regex, then runs further regex replacements on each part. This happens twice per render (once for images, once for links).
### Technical Details
- **File**: `src/components/chat-components/ChatSingleMessage.tsx:378-395`
- **Code**: `text.split(/(```[\s\S]*?```|`[^`]\*`)/g)` creates an array of code/non-code segments, then regex replacement runs on each non-code segment. Called twice in the preprocessing pipeline.
- For long AI responses with many code blocks, this is O(parts x content_length) per call.
### Recommended Solution
Split the content into code/non-code segments once, then apply all transformations to the non-code segments in a single pass.
### Impact
- **Affects**: Long AI responses during streaming.
- **Severity scales with**: Message length and number of code blocks.
---
## 12. [TODO] Missing React.memo on Frequently-Rendered Leaf Components (LOW)
### Issue Description
Several leaf components that render frequently due to parent re-renders are not wrapped in `React.memo`.
### Technical Details
- **ChatButtons** (`src/components/chat-components/ChatButtons.tsx`): Renders for every message, receives callbacks that change on parent re-render.
- **MessageContext** (`src/components/chat-components/ChatSingleMessage.tsx:85`): Renders context badges for each message.
- **ChatHistoryItem** (`src/components/chat-components/ChatHistoryPopover.tsx:349`): Receives `confirmDeleteId` which changes for all items on any delete confirmation.
### Recommended Solution
Wrap each in `React.memo`. For `ChatHistoryItem`, consider passing only a boolean `isConfirmingDelete` instead of the full `confirmDeleteId` to reduce unnecessary re-renders.
### Impact
- **Individually negligible**, but compounds with other findings.
---
## 13. [TODO] useAtMentionSearch Eagerly Creates React Elements for All Vault Items (LOW)
### Issue Description
The `noteItems`, `folderItems`, and `webTabItems` memos create `React.createElement` for icon components on every item, even when the typeahead menu is not open.
### Technical Details
- **File**: `src/components/chat-components/hooks/useAtMentionSearch.ts:45-113`
- For vaults with 5000+ notes, this creates 5000+ React elements on mount.
- The `useMemo` deps include `allNotes` which changes on every vault event.
### Recommended Solution
Defer icon element creation to render time (pass icon component type instead of element instance), or only compute items when the typeahead is open.
### Impact
- **Affects**: Initial mount time and memory in large vaults.
- **Mitigated by**: `useMemo` — only recomputes when deps change.
---
## 14. [TODO] ChatHistoryPopover ChatHistoryItem Not Memoized (LOW)
### Issue Description
`ChatHistoryItem` receives several props that change across all items when any single item is being edited or deleted.
### Technical Details
- **File**: `src/components/chat-components/ChatHistoryPopover.tsx:349-486`
- `confirmDeleteId` changes for all items when any delete is confirmed.
- `editingTitle` changes on every keystroke during editing.
- All items re-render when either of these change.
### Recommended Solution
Wrap `ChatHistoryItem` in `React.memo`. Pass derived booleans (`isConfirmingDelete`, `isEditing`) instead of global IDs.
### Impact
- **Mitigated by**: Pagination (max 50 items rendered at a time).
- **Nearly negligible** with current architecture.
---
## Good Patterns Already in Place
These patterns were identified as already well-implemented:
- **RAF-throttled streaming**: `useRafThrottledCallback` properly throttles streaming text updates to animation frames
- **ChatMessages memo**: The top-level `ChatMessages` is wrapped in `React.memo` (though currently defeated by unstable props — see Findings #1, #4, #9)
- **Pagination in ChatHistoryPopover**: IntersectionObserver-based infinite scroll prevents rendering all history at once
- **VaultDataManager debouncing**: 250ms debounce on vault events prevents rapid-fire re-scans
- **useLayoutEffect for pagination reset**: Prevents one-frame render spike when popover opens
- **RelevantNotes memo**: Properly memoized with `React.memo`
- **Streaming message isolation**: The streaming message uses a separate `ChatSingleMessage` instance with a stable key, preventing full list re-keying
---
## Recommended Fix Priority Order
| Priority | Finding | Effort | Expected Impact |
| -------- | -------------------------------------------------- | ---------- | ------------------------------------------ |
| P0 | #1 Memoize ChatSingleMessage + stabilize callbacks | Medium | Eliminates streaming jank |
| P0 | #2 Hoist filter out of .map() | Trivial | O(N^2) -> O(N) per render |
| P1 | #9 Stabilize Chat.tsx callback dependencies | Small | Unbreaks ChatMessages memo |
| P1 | #4 Stabilize chatHistory array reference | Small | Prevents cascading re-renders |
| P1 | #7 Extract loading dots component | Trivial | Eliminates 5 re-renders/sec during loading |
| P2 | #3 Cache ChatPersistenceManager | Trivial | Reduces GC pressure |
| P2 | #5 Decouple scroll calculation from chatHistory | Small | Eliminates layout thrashing |
| P2 | #6 Pre-sort notes in VaultDataManager | Trivial | Reduces sort cost in large vaults |
| P3 | #8 Merge tag refresh into single pass | Small | Halves vault scan frequency |
| P3 | #10-14 Remaining medium/low items | Small each | Incremental improvements |
---
_Last updated: 2026-03-03_

View file

@ -0,0 +1,174 @@
# Agent Mode and Tools
Copilot Plus includes an **autonomous agent** that can reason step-by-step and decide which tools to use to answer your question. Instead of you specifying every step, the agent figures out what to do on its own.
This feature requires a [Copilot Plus](copilot-plus-and-self-host.md) license.
---
## Overview
When the autonomous agent is enabled, Copilot can:
1. Break down your request into sub-tasks
2. Use tools to gather information (search your vault, search the web, read a note)
3. Create or edit notes
4. Combine results and give you a comprehensive answer
**Example**: Ask "What did I work on last week?" and the agent will automatically search your vault for dated notes from the past 7 days, read the relevant ones, and summarize your week.
---
## Enabling Agent Mode
1. Go to **Settings → Copilot → Plus**
2. Turn on **Enable Autonomous Agent**
The agent activates automatically when you're in **Copilot Plus** mode. You don't need to do anything special — just ask your question.
### Max Iterations
The agent works in iteration cycles (think → use a tool → think → use a tool → answer). You can control the maximum number of iterations before the agent stops:
- **Default**: 4 iterations
- **Maximum**: 16 iterations
- **Setting**: **Settings → Copilot → Plus → Autonomous Agent Max Iterations**
The agent also has a maximum runtime of 5 minutes per response, regardless of iteration count.
---
## Available Tools
Copilot Plus has 13 built-in tools. Some are always active; others can be enabled or disabled.
### Always-Enabled Tools
These tools are always available and cannot be disabled:
#### Get Current Time
Gets the current time in any timezone. Useful for time-aware queries like "what should I do today?"
#### Get Time Range
Converts natural time expressions (like "last week" or "yesterday") into exact date ranges. Usually called automatically before a time-based vault search.
#### Get Time Info
Converts an epoch timestamp to a human-readable date and time.
#### Convert Timezones
Converts a time from one timezone to another. Ask: "What time is 3pm EST in Tokyo?"
#### Read Note
Reads the content of a specific note. The agent uses this to inspect a note it found via search, or that you mentioned explicitly. Works on large notes by reading them in chunks.
#### File Tree
Browses the file structure of your vault. The agent uses this to find folder paths before creating new notes or to count files in a folder.
#### Tag List
Lists all tags in your vault with usage statistics. Useful for tag reorganization or finding notes by tag patterns.
#### Update Memory
Saves information to your memory when you explicitly ask the AI to remember something. See [Copilot Plus and Self-Host](copilot-plus-and-self-host.md#memory-system) for details.
> **Requires**: **Settings → Copilot → Plus → Reference Saved Memories** must be enabled. If this setting is off, the tool is not registered and memory commands will not work.
### Configurable Tools
These tools can be individually enabled or disabled in **Settings → Copilot → Plus → Tool Settings**:
#### Vault Search
Searches your vault notes by content. The agent uses this to find notes relevant to your question.
- **Trigger**: Automatically for vault-related questions, or explicitly with `@vault`
- **Uses**: Both semantic search (if enabled) and lexical search
#### Web Search
Searches the internet for current information.
- **Trigger**: Automatically when your question implies web/online content, or explicitly with `@websearch` or `@web`
- **Requires**: A web search service configured (Firecrawl or Perplexity in self-host mode, or handled by Plus)
#### Write to File
Creates a new note or overwrites an existing one entirely.
- **Trigger**: Automatically for "create a note" requests, or explicitly with `@composer` (available in both Copilot Plus and Projects mode)
- **Behavior**: Shows a preview of the content before writing. You can review and accept or reject the change.
- **Auto-accept**: Enable **Settings → Copilot → Plus → Auto-accept edits** to skip the preview
#### Replace in File
Makes targeted changes to an existing note using search-and-replace blocks.
- **Use case**: Small edits (adding a bullet, updating a section) — more precise than rewriting the whole note
- **Behavior**: Shows a diff preview before applying the change
- **Auto-accept**: Same setting as Write to File
#### YouTube Transcription
Fetches the transcript of a YouTube video.
- **Trigger**: Automatically when you paste a YouTube URL in your message
- **No extra setup needed**: Just include the URL in your message
- **Self-host option**: Use your own Supadata API key for transcription in self-host mode
---
## Tool Settings
Go to **Settings → Copilot → Plus → Tool Settings** to:
- See all available tools
- Enable or disable individual configurable tools
- View what each tool does
---
## Using Tools Explicitly
While the agent automatically decides when to use tools, you can also trigger them explicitly with @-mentions:
```
@vault find all notes about my reading list
@websearch what is the latest version of Python?
@composer create a new meeting notes template
@memory remember that I prefer bullet points for lists
```
See [Context and Mentions](context-and-mentions.md) for the full @-mention reference.
---
## Tool Call Indicators
While the agent is working, the chat shows status indicators for each tool call:
- "Reading files"
- "Searching the web"
- "Reading file tree"
- "Compacting"
This lets you see what the agent is doing as it works.
---
## File Editing: Preview and Diff
When the agent uses **Write to File** or **Replace in File**, it shows a preview before making changes:
- **Split view**: Before/after shown side by side
- **Side-by-side view**: Changes highlighted inline
You can choose your preferred diff view in **Settings → Copilot → Plus → Diff View Mode**.
Review the proposed change and click:
- **Accept** — Apply the change to your note
- **Reject** — Discard without making any changes
- **Revert** — Undo a change that was already accepted
### Auto-Accept Edits
If you trust the agent and don't want to review every file change, enable **Auto-accept edits** in **Settings → Copilot → Plus**. File changes will be applied immediately without a confirmation step.
---
## Related
- [Copilot Plus and Self-Host](copilot-plus-and-self-host.md) — Licensing and memory
- [Vault Search and Indexing](vault-search-and-indexing.md) — How vault search works
- [Context and Mentions](context-and-mentions.md) — @-mention triggers for tools

159
docs/chat-interface.md Normal file
View file

@ -0,0 +1,159 @@
# Chat Interface
The Copilot chat panel is the main way you interact with AI in Obsidian. This guide covers everything about the chat UI: modes, message controls, history, settings, and advanced features like auto-compact.
---
## Chat Modes
Copilot offers four modes. You can switch between them using the mode selector at the top of the chat panel.
### Chat
General-purpose conversation. Good for writing, brainstorming, summarizing, or any task where you want to talk to an AI. Your currently open note and selected text are automatically included as context.
### Vault QA (Basic)
Ask questions about your vault content. Copilot uses lexical search (keyword matching) to find relevant notes and passes them as context to the AI. No indexing required. Good for quick questions about your notes.
### Copilot Plus
The most powerful mode. Requires a [Copilot Plus](copilot-plus-and-self-host.md) license. Combines Chat and Vault QA with an autonomous agent that can:
- Search your vault and the web
- Read and edit notes
- Remember things across conversations
- Use a growing set of tools automatically
### Projects (alpha)
Focused workspaces with their own context, model, system prompt, and isolated chat history. Useful for keeping separate AI conversations per project. See [Projects](projects.md) for details.
---
## Sending Messages
Type your message in the input box at the bottom of the chat panel and press **Enter** to send (or **Shift+Enter** to add a new line). You can change the send key in Settings → Basic → **Default Send Shortcut**.
While the AI is generating a response, a **Stop** button appears. Click it to interrupt the stream at any time.
### Referencing Notes Inline
You can mention specific notes directly in your message using double-bracket syntax:
```
[[Note Title]]
```
Copilot adds the note's content to your message as context in the background. This is different from @-mentions — it's typed directly in your message text.
### User Message Buttons
Each message you send has action buttons that appear on hover:
- **Edit** — Modify your prompt. Press Enter to re-send the edited message to the AI.
- **Copy** — Copy the message text to clipboard
- **Delete** — Remove this message from the conversation
### AI Message Buttons
Each AI response has action buttons:
- **Insert at cursor** — Insert the AI's response at your cursor position in the active note
- **Replace at cursor** — Replace the selected text in your note with the AI's response
- **Copy** — Copy the response to clipboard
- **Regenerate** — Ask the AI to generate a new response to the same message
- **Delete** — Remove this response from the conversation
---
## Chat History
### Autosave
By default, Copilot automatically saves your conversations as markdown files in your vault. Each saved chat appears in the `copilot/copilot-conversations/` folder.
You can turn off autosave in Settings → Basic. When you start a new chat, any unsaved conversation is saved automatically.
### Chat File Name Format
The filename template controls how saved chats are named. The default is:
```
{$topic}@{$date}_{$time}
```
Where:
- `{$topic}` — An AI-generated title (or the first few words of your first message if AI titles are off)
- `{$date}` — Date in YYYY-MM-DD format
- `{$time}` — Time in HH-MM-SS format
All three variables are required. You can customize the format in Settings → Basic → **Conversation note name**.
### AI-Generated Titles
When **Generate AI chat title on save** is enabled (default), Copilot asks the AI to generate a short, descriptive title for the conversation when saving. When disabled, the first 10 words of your first message are used instead.
### Loading Previous Chats
Click the **clock/history icon** in the chat panel toolbar to open the Chat History list. You can:
- Browse previous conversations
- Click a conversation to load it and continue from where you left off
- Delete conversations you no longer need
The history list can be sorted by most recent or alphabetically.
---
## Per-Session Settings (Gear Icon)
Click the **gear icon** inside the chat panel to open per-session settings. These apply only to the current conversation and reset when you start a new chat:
- **System prompt** — Override the default system prompt for this session
- **Temperature** — Controls randomness (0 = deterministic, 1 = creative)
- **Max tokens** — Maximum length of the AI's response
---
## Token Counter
Copilot shows a token count indicator at the bottom of the chat. This estimates how many tokens are being used by your current context. Useful for knowing when you're approaching context limits.
---
## Auto-Compact
When a conversation grows very long, it can exceed the model's context window. Auto-compact automatically summarizes the older portion of the conversation and replaces it with a compressed summary, letting you continue chatting without losing track of what was discussed.
The threshold is configured in Settings → Basic → **Auto-compact threshold**, which defaults to 128,000 tokens. Valid range: 64,0001,000,000 tokens.
When auto-compact triggers, you'll see a "Compacting" indicator in the chat. The conversation continues normally — older messages are replaced by a summary, so the AI still understands the history even though you can no longer scroll back to see the original messages.
---
## Suggested Prompts
When starting a new chat, Copilot may show suggested prompts based on your active note or previous conversations. You can enable or disable this in Settings → Basic → **Show suggested prompts**.
## Relevant Notes
Copilot can display a list of notes related to your currently active note in the chat panel. This helps surface notes you might want to reference without manually searching.
Enable in **Settings → Copilot → Basic → Relevant Notes** (on by default).
## Saving a Chat Manually
If autosave is off, or you want to save mid-conversation, click the **Save Chat as Note** button above the chat input box. This saves the current conversation to your configured save folder.
---
## New Chat Behavior
Click the **pencil/new chat icon** to start a fresh conversation. This:
1. Saves the current conversation (if autosave is enabled)
2. Clears the chat window
3. Resets the context to your currently active note
You can also use the command palette: **New Copilot Chat**.
---
## Related
- [Context and Mentions](context-and-mentions.md) — Control what context the AI sees
- [System Prompts](system-prompts.md) — Customize AI behavior with system prompts
- [Agent Mode and Tools](agent-mode-and-tools.md) — What Plus mode can do
- [Projects](projects.md) — Isolated workspaces with separate histories

View file

@ -0,0 +1,145 @@
# Context and Mentions
Copilot uses **context** to give the AI information about your notes, selected text, web content, and more. You can control exactly what context the AI sees using automatic context, @-mentions, and manual commands.
---
## Automatic Context
### Active Note
By default, the content of your currently open note is automatically included in every message you send. This means you can ask things like:
- "Summarize this note"
- "What are the action items here?"
- "Add a conclusion section"
To disable automatic note context: **Settings → Copilot → Basic → Auto-add active note to context** (toggle off).
### Active Web Tab (Desktop Only)
If you have the Copilot Web Viewer open alongside your notes, the content of the currently active web tab is automatically included as context (labeled `{activeWebTab}`). This lets you ask the AI to help you work with web content.
### Selected Text
If you highlight text in a note and then type in the chat, the selected text is automatically included as context. This is useful for asking about or transforming a specific part of a note.
You can enable/disable automatic selection adding in **Settings → Copilot → Basic → Auto-add selection to context**.
### Images in Markdown
If your note contains images (e.g., `![[screenshot.png]]`), and you're using a model with **Vision** capability, those images are automatically included in the context. Copilot will pass the image data to the AI so it can see and describe the image.
To control this behavior: **Settings → Copilot → Basic → Pass markdown images to AI**.
---
## @-Mentions
Type `@` in the chat input to mention and include specific items as context.
### @note — Include a Specific Note
Type `@` followed by the note title to add a note to context:
```
@My Meeting Notes tell me what was decided in this meeting
```
The note's full content is included in the request.
### @folder — Include a Folder of Notes
Type `@` followed by a folder name to include all notes in that folder:
```
@Projects/ what tasks are still open?
```
### @tags — Include Notes by Tag
Use `#` after `@` to include all notes with a specific tag:
```
@#work/project summarize the status of the work project
```
### @URL — Include a Web Page
Paste a URL or type `@https://...` to fetch and include a web page's content:
```
@https://example.com/article summarize this article
```
URL processing requires Copilot Plus. YouTube URLs are handled specially — Copilot will fetch the video transcript automatically.
### Tool Mentions
These special @-mentions explicitly trigger tools in Copilot Plus mode:
| Mention | What it does |
|---|---|
| `@vault` | Search your vault notes for relevant information |
| `@websearch` or `@web` | Search the internet |
| `@composer` | Create or edit a note |
| `@memory` | Access or update your memory |
Example:
```
@vault what did I write about machine learning last month?
@websearch what are the latest changes to the Python packaging ecosystem?
```
---
## Adding Context Manually
### Add Selection to Chat Context
Use the command palette: **Add selection to chat context**
Highlights the selected text and adds it to the chat as context without sending a message. Useful when you want to build up context before sending.
### Add Web Selection to Chat Context
Use the command palette: **Add web selection to chat context**
Works similarly but captures selected text from the Web Viewer. Available on desktop only.
### Adding a PDF as Context (Copilot Plus)
Click the **+ Add context** button above the chat input to attach a PDF file. The PDF is converted to text and included as context for your message.
### Adding an Image as Context
Drag an image directly into the chat input box, or click the **image button** in the bottom-right corner of the chat input. The image is sent to the AI if your selected model supports **Vision** capability.
---
## Context Indicators
When context items are added to your message, Copilot shows small pills or badges in the chat input area showing what's included (e.g., the note name, a URL, a tag). This helps you confirm exactly what the AI will see.
---
## Context Behavior by Mode
| Context Type | Chat | Vault QA | Copilot Plus |
|---|---|---|---|
| Active note | Yes (auto) | Yes (auto) | Yes (auto) |
| Selected text | Yes (auto) | Yes (auto) | Yes (auto) |
| @note / @folder | Yes | Yes | Yes |
| @URL processing | Copilot Plus only | Copilot Plus only | Yes |
| @vault search | Yes (explicit) | Auto | Auto |
| @websearch | No | No | Yes |
| Images (vision) | Yes | Yes | Yes |
| Active web tab | Desktop only | Desktop only | Desktop only |
---
## Related
- [Chat Interface](chat-interface.md) — How the chat panel works
- [Agent Mode and Tools](agent-mode-and-tools.md) — More on @vault and @websearch
- [Vault Search and Indexing](vault-search-and-indexing.md) — How vault search works

View file

@ -0,0 +1,157 @@
# Copilot Plus and Self-Host
**Copilot Plus** is a premium tier that unlocks advanced features beyond the free, API-key-based experience. **Self-Host Mode** is an additional option for Copilot Plus Lifetime/Believer subscribers who want to run their own infrastructure.
---
## Copilot Plus
### What Is Copilot Plus?
Copilot Plus is a subscription that enables:
- **Autonomous agent mode** — AI that reasons step-by-step and uses tools automatically
- **File editing tools** — Write to File and Replace in File for AI-driven note editing
- **Web search** — Search the internet from chat
- **YouTube transcription** — Fetch video transcripts and use them as context
- **Memory system** — Persistent memory across conversations
- **Copilot Plus Flash model** — A built-in model that requires no separate API key
- **URL processing** — Fetch and summarize web pages as context
- **Copilot Plus embedding models** — High-quality embeddings for semantic search
### Setting Up Copilot Plus
1. Get a license key from your dashboard at **https://www.obsidiancopilot.com/en/dashboard**
2. Go to **Settings → Copilot → Basic** (or the Plus banner in the settings)
3. Enter your license key in the **Copilot Plus License Key** field
4. Features unlock automatically
---
## Copilot Plus Flash Model
**Copilot Plus Flash** is a built-in AI model included with your Copilot Plus subscription:
- No separate API key needed
- Works out of the box once your license key is active
- Supports vision (image inputs)
- Good for general-purpose tasks
It appears as `copilot-plus-flash` in the model selector.
---
## Memory System
The memory system lets Copilot remember things across conversations, so you don't have to repeat yourself.
### Recent Conversations
Copilot can reference your recent conversation history to provide more contextually relevant responses. This is separate from the current chat window — it's a summary of what you've been working on.
- **Enable**: **Settings → Copilot → Plus → Reference Recent Conversation** (on by default)
- **How many**: **Settings → Copilot → Plus → Max Recent Conversations** — default 30, range 1050
- All history is stored locally in your vault (no data leaves your machine for this feature)
### Saved Memories
You can ask Copilot to explicitly remember specific facts about you:
```
@memory remember that I'm preparing for JLPT N3 and prefer bullet-point summaries
```
Copilot saves this to a memory file in your vault and references it in future conversations.
- **Enable**: **Settings → Copilot → Plus → Reference Saved Memories** (on by default)
- **Memory folder**: **Settings → Copilot → Plus → Memory Folder Name** — default: `copilot/memory`
- **Update memory tool**: The AI can add, update, or remove memories when you ask
---
## Document Processor
When Copilot processes PDFs and other non-markdown files (in Plus mode), it converts them to markdown for the AI to read.
You can optionally save the converted markdown to a folder in your vault:
- **Setting**: **Settings → Copilot → Plus → Store converted markdown at**
- Leave empty to skip saving (conversion still happens, it just isn't persisted)
---
## Self-Host Mode
### What Is Self-Host Mode?
Self-Host Mode lets you replace Copilot's cloud services with your own infrastructure. Instead of relying on Copilot's Plus backend, you run everything locally or on your own server.
**Requires**: A Copilot Plus Lifetime or Believer license (not available on monthly subscriptions).
### What Self-Host Mode Enables
- Use local or custom LLM servers
- Custom web search via Firecrawl or Perplexity Sonar
- Local YouTube transcript extraction via Supadata
- Miyo desktop app for local PDF parsing, semantic search, and more
### Enabling Self-Host Mode
1. Go to **Settings → Copilot → Plus**
2. Under **Self-Host Mode**, toggle **Enable Self-Host Mode**
3. Copilot validates your license. If valid, the toggle activates.
4. Toggle **Enable Miyo** to use the Miyo desktop app for local search, PDF parsing, and context.
5. *(Optional)* Set **Custom Miyo Server URL** only if Miyo is running on a remote machine. Leave blank to use automatic local service discovery.
### Web Search in Self-Host Mode
Choose your web search provider:
- **Firecrawl** — A web crawling and scraping API. Get a key at firecrawl.dev. Enter it in **Settings → Copilot → Plus → Firecrawl API Key**.
- **Perplexity Sonar** — An AI-powered search API. Get a key at perplexity.ai. Enter it in **Settings → Copilot → Plus → Perplexity API Key**.
### YouTube Transcription in Self-Host Mode
Use your own Supadata API key for YouTube transcript extraction:
- Get a key at supadata.ai
- Enter it in **Settings → Copilot → Plus → Supadata API Key**
---
## Miyo Desktop App
Miyo is a companion desktop app from the same developer that enhances Copilot with local, offline capabilities:
### What Miyo Provides
- **Local semantic search** — Fast vector search without embedding API calls
- **PDF parsing** — Converts PDFs to markdown locally (no cloud OCR)
- **Context hub** — Manages your indexed documents locally
- **Custom server URL** — Run Miyo on any machine (local or server)
### Setting Up Miyo
1. Download and install the Miyo desktop app
2. Start the Miyo server
3. In Copilot, go to **Settings → Copilot → Plus → Enable Miyo Search**
4. Miyo automatically connects to the local server (or use a custom URL in **Miyo Server URL**)
5. Index your vault — Copilot will use Miyo to generate and store embeddings locally
### Custom Miyo Server URL
If Miyo is running on a different machine (e.g., a home server), enter its address:
```
http://192.168.1.10:8742
```
Leave empty to use automatic local discovery.
---
## Related
- [Agent Mode and Tools](agent-mode-and-tools.md) — Using the autonomous agent
- [Vault Search and Indexing](vault-search-and-indexing.md) — How Miyo enhances semantic search
- [Getting Started](getting-started.md) — First-time setup

154
docs/custom-commands.md Normal file
View file

@ -0,0 +1,154 @@
# Custom Commands
Custom commands are preset AI prompts you define once and reuse on any note or selected text. They're stored as markdown files in your vault and can be triggered from the right-click context menu, the command palette, or as slash commands in chat.
---
## Overview
A custom command is like a template prompt. You write an instruction (with optional variables) and save it. From then on, you can apply it to any note or selected text with a single click.
**Examples of what you might create:**
- "Summarize this note in bullet points"
- "Extract all action items as a task list"
- "Rewrite this in a more formal tone"
- "Translate to Spanish"
- "Create a Fleeting Note from this"
---
## Creating a Custom Command
### From Settings
1. Go to **Settings → Copilot → Command**
2. Click **Add new command**
3. Fill in the fields:
- **Name** — What the command is called (also becomes its ID)
- **Prompt** — The instruction to send to the AI
- **Show in context menu** — Whether it appears when right-clicking text in a note
- **Model** — Optional: use a specific model for this command (defaults to the current chat model)
4. Save
### From the Command Palette
You can also create a command on the fly:
1. Open the command palette (`Ctrl/Cmd+P`)
2. Run **Add new custom command**
3. A form will open to fill in the command details
---
## Prompt Template Variables
Inside your prompt, you can use variables that get replaced with real content when the command runs:
| Variable | What it inserts |
|---|---|
| `{}` or `{selected_text}` | The text currently selected in the editor |
| `{activeNote}` | The full content of the currently active note |
| `{[[Note Title]]}` | The content of a specific note by title |
| `{FolderPath}` | All notes within a specific folder |
| `{#tag1, #tag2}` | All notes with any of the specified tags |
> **Important**: Tags in `{#tag1, #tag2}` must be in the note's **properties (frontmatter)**, not inline tags within the note body.
**Example — quiz generator using two variables:**
```
Come up with multiple choice questions using {activeNote}, and follow
the format of {[[Quiz Template]]} to start a quiz session.
Ask one question at a time, stop and wait for the user.
After the user answers, provide the correct answer and explanation.
Repeat until the user says STOP.
```
**Example — comparison using specific notes:**
```
Compare my notes on {[[Product Roadmap]]} and {[[Competitor Analysis]]} and identify gaps.
```
**Example — acting on selected text:**
```
Rewrite this in a more formal tone: {selected_text}
```
Variable substitution must be enabled in **Settings → Copilot → Command → Enable custom prompt templating** (on by default).
---
## Using Commands
### From the Right-Click Context Menu
If a command has **Show in context menu** enabled:
1. Select some text in a note (optional)
2. Right-click to open the context menu
3. Hover over **Copilot** → select your command
4. The AI processes your selection or note and shows the result
### From the Command Palette
1. Select text or open the note you want to work with
2. Open the command palette (`Ctrl/Cmd+P`)
3. Run **Apply custom command**
4. Pick your command from the list
### As a Slash Command in Chat
Inside the chat input, type `/` followed by the command name to run it:
```
/summarize
```
The command runs in the context of your current chat session and active note.
> **Note**: The `@composer` mention (for AI note editing) requires Copilot Plus. In free modes, `@composer` will not be available.
---
## Managing Commands
Go to **Settings → Copilot → Command** to manage all your custom commands:
- **Edit** — Click the edit icon next to any command
- **Reorder** — Drag commands to change their order (affects the context menu and command list)
- **Duplicate** — Copy an existing command as a starting point
- **Delete** — Remove a command permanently
- **Sort strategy** — Choose how commands are sorted: manually, by recent use, or alphabetically
### Custom Prompts Folder
Commands are stored as markdown files in your vault. The default folder is `copilot/copilot-custom-prompts/`. You can change this in **Settings → Copilot → Basic → Custom prompts folder**.
---
## Quick Command
**Quick Command** opens a modal where you can run a one-off AI prompt on your selected text without creating a permanent command.
- **Trigger**: Command palette → **Trigger quick command**
- **Assign a hotkey**: Settings → Hotkeys → search "Trigger quick command"
- **Behavior**: Opens a prompt input, lets you choose a model and whether to include the note context, then runs the prompt on your selection
---
## Quick Ask
**Quick Ask** is a floating inline panel that appears at the cursor position in your editor. It's designed for quick, in-context AI queries while you're writing.
- **Trigger**: Command palette → **Quick Ask** (or assign a hotkey, recommended: `Ctrl/Cmd+K`)
- **Not available in Source Mode** — Works in Live Preview and Reading view
- **How it works**: A small input appears right where your cursor is. Type your question, press Enter, and the response appears inline.
Quick Ask is great for things like "rephrase this sentence," "what does this term mean?", or "suggest three alternatives."
---
## Related
- [Chat Interface](chat-interface.md) — Using slash commands in chat
- [Context and Mentions](context-and-mentions.md) — How context is passed to commands
- [Agent Mode and Tools](agent-mode-and-tools.md) — More powerful note editing with @composer

148
docs/getting-started.md Normal file
View file

@ -0,0 +1,148 @@
# Getting Started with Copilot for Obsidian
Copilot for Obsidian is an AI-powered plugin that brings large language models (LLMs) directly into your note-taking workflow. You can chat with AI, ask questions about your vault, run custom commands, search the web, and even have the AI edit your notes — all without leaving Obsidian.
## What Can Copilot Do?
- **Chat**: Have a conversation with an AI assistant
- **Vault Q&A**: Ask questions and get answers grounded in your own notes
- **Note editing**: Ask the AI to write or update your notes for you
- **Semantic search**: Find notes by meaning, not just keywords
- **Custom commands**: Run AI-powered prompts on selected text
- **Web search**: Fetch and summarize information from the internet
- **Memory**: Have the AI remember facts about you across conversations
Copilot supports 16+ AI providers including OpenAI, Anthropic, Google Gemini, Ollama (local), and more.
---
## Installation
1. Open **Obsidian Settings** → **Community plugins**
2. Turn off **Safe mode** if prompted
3. Click **Browse** and search for **Copilot**
4. Click **Install**, then **Enable**
Copilot is now installed. A robot icon will appear in the left sidebar ribbon.
---
## First-Time Setup
### Step 1: Open Plugin Settings
Go to **Settings****Copilot** (scroll down to the Community Plugins section).
### Step 2: Add an API Key
On the **Basic** tab, click **Set Keys** to open the API key dialog. Enter the key for your chosen provider:
| Provider | Where to get a key |
|---|---|
| OpenRouter (default) | https://openrouter.ai/keys |
| OpenAI | https://platform.openai.com/api-keys |
| Anthropic | https://console.anthropic.com/settings/keys |
| Google Gemini | https://makersuite.google.com/app/apikey |
The default model is **OpenRouter Gemini 2.5 Flash**, which requires an OpenRouter API key. If you'd prefer a different provider, set up that key first, then change the default model.
### Step 3: Choose a Default Model
Still on the **Basic** tab, use the **Default Chat Model** dropdown to select the model you want to use. Any model whose provider has an API key configured will be available.
### Step 4: Choose a Chat Mode
Use the **Default Mode** dropdown to set which mode opens by default:
- **Chat** — General conversation, good for most tasks
- **Vault QA** — Ask questions answered from your notes
- **Copilot Plus** — Advanced mode with autonomous agent and tools (requires Copilot Plus license)
- **Projects** — Focused workspaces (alpha feature)
Most users should start with **Chat** mode.
---
## Opening the Chat Panel
You can open Copilot in several ways:
- Click the **robot icon** in the left ribbon (sidebar)
- Use the command palette: `Ctrl/Cmd+P` → **Open Copilot Chat Window**
- Use the hotkey `Ctrl/Cmd+P`**Toggle Copilot Chat Window** to show/hide it
### Sidebar vs. Editor Tab
By default, Copilot opens as a **view** (sidebar panel). You can change this in Settings → Copilot → Basic → **Open chat in**:
- **View** — Opens in the sidebar, stays visible as you work
- **Editor** — Opens as an editor tab, giving it more screen space
---
## Your First Conversation
1. Open the chat panel
2. Type your message in the input box at the bottom
3. Press **Enter** (or **Shift+Enter** if you changed the send shortcut) to send
4. Watch the AI's response stream in real time
5. Continue the conversation naturally
The AI will automatically include your currently open note as context, so you can say things like "summarize this note" or "what are the action items in this note?"
---
## Keyboard Shortcuts
These are the default shortcuts. You can customize them in **Obsidian Settings****Hotkeys** → search for "Copilot".
| Action | Default Shortcut |
|---|---|
| Open Copilot Chat Window | *(unbound — assign in Hotkeys)* |
| Toggle Copilot Chat Window | *(unbound — assign in Hotkeys)* |
| New Copilot Chat | *(unbound — assign in Hotkeys)* |
| Quick Ask (floating input) | *(unbound — assign in Hotkeys)* |
| Trigger Quick Command | *(unbound — assign in Hotkeys)* |
| Add selection to chat context | *(unbound — assign in Hotkeys)* |
### Send Shortcut
By default, **Enter** sends a message and **Shift+Enter** adds a new line. You can swap this in Settings → Copilot → Basic → **Default Send Shortcut**.
---
---
## Glossary
**LLM (Large Language Model)**
The AI "brain" behind Copilot — a model trained on vast text to understand and generate human language, powering chat, summarization, and writing assistance.
**API (Application Programming Interface)**
A way for Copilot to communicate with external AI services. You provide an API key, which is like a password that lets Copilot use a provider's AI models on your behalf. Note: an OpenAI API key is *different* from a ChatGPT Plus subscription — you don't need ChatGPT Plus to use Copilot.
**API Key**
A secret token from an AI provider that authorizes Copilot to make requests. Most providers require you to have a billing account with a positive balance.
**Token**
A small unit of text (roughly ¾ of a word) that AI models process. Tokens measure how much text the AI can handle at once and relate to usage costs.
**Context Window**
The amount of text the AI can consider at one time when generating a response. A larger context window means the AI can use more of your notes or conversation history.
**Embeddings**
A method of converting text into numbers that capture meaning. Embeddings let the AI find notes that are conceptually related, even if they don't share exact words.
**RAG (Retrieval-Augmented Generation)**
A technique that enhances AI responses by first searching for relevant notes, then generating an answer based on both your query and the retrieved content. This is how Vault QA works.
**Vector Store / Index**
A database that stores your notes as mathematical vectors (embeddings) so they can be searched by meaning. Think of it as a smart index that understands the context of your notes, not just their keywords.
---
## Next Steps
- [Chat Interface](chat-interface.md) — Learn about modes, history, and settings
- [LLM Providers](llm-providers.md) — Set up your preferred AI provider
- [Context and Mentions](context-and-mentions.md) — Control what context the AI sees
- [Vault Search and Indexing](vault-search-and-indexing.md) — Set up semantic search over your notes

29
docs/index.md Normal file
View file

@ -0,0 +1,29 @@
# Copilot for Obsidian — Documentation
Welcome to the official documentation for **Copilot for Obsidian**, an AI-powered assistant plugin that brings the power of large language models directly into your note-taking workflow.
## Table of Contents
| Document | What it covers |
|---|---|
| [Getting Started](getting-started.md) | Installation, first-time setup, opening the chat panel, keyboard shortcuts |
| [Chat Interface](chat-interface.md) | Chat modes, sending messages, history, settings, auto-compact |
| [LLM Providers](llm-providers.md) | All 16+ supported providers and how to set them up |
| [Models and Parameters](models-and-parameters.md) | Chat models, embedding models, temperature, max tokens, and other parameters |
| [Context and Mentions](context-and-mentions.md) | Active note context, @-mentions, URLs, tags, and the web viewer |
| [Custom Commands](custom-commands.md) | Creating and using preset prompts, template variables, Quick Command, Quick Ask |
| [Vault Search and Indexing](vault-search-and-indexing.md) | Lexical search, semantic search, index management, exclusions |
| [Agent Mode and Tools](agent-mode-and-tools.md) | Autonomous agent, all 13 tools, file editing, web search |
| [Projects](projects.md) | Focused workspaces with isolated context, model, and chat history |
| [System Prompts](system-prompts.md) | Customizing AI behavior with built-in and custom system prompts |
| [Copilot Plus and Self-Host](copilot-plus-and-self-host.md) | Copilot Plus features, memory system, self-host mode, Miyo |
| [Troubleshooting and FAQ](troubleshooting-and-faq.md) | Common errors, provider-specific issues, performance, FAQ |
## Quick Start
1. Install Copilot from Obsidian Community Plugins
2. Add an API key in Settings → Copilot → Basic → API Keys
3. Open the chat panel with the robot icon in the left ribbon
4. Start chatting!
For a full walkthrough, see [Getting Started](getting-started.md).

189
docs/llm-providers.md Normal file
View file

@ -0,0 +1,189 @@
# LLM Providers
Copilot includes 16 built-in AI providers, and you can add an unlimited number of additional models as long as they are OpenAI-compatible. You can use cloud-based services that require API keys, or run models locally on your own machine. This guide explains how to set up each provider.
---
## How to Set API Keys
1. Go to **Settings → Copilot → Basic**
2. Click **Set Keys** to open the API key dialog
3. Enter your key for the provider you want to use
4. Click Save
You can configure multiple providers simultaneously and switch between them by changing the default model.
---
## Cloud Providers
### OpenRouter (Default)
OpenRouter is a gateway that provides access to hundreds of models from many providers through a single API key.
- **Get a key**: https://openrouter.ai/keys
- **Default model**: OpenRouter Gemini 2.5 Flash
- **Why use it**: One key, many models. Good starting point.
- **Setting key**: `openRouterAiApiKey`
### OpenAI
Direct access to GPT-4.1, GPT-5, and other OpenAI models.
- **Get a key**: https://platform.openai.com/api-keys
- **Models include**: GPT-5.4, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o4-mini (reasoning)
- **Setting key**: `openAIApiKey`
### Anthropic
Access to Claude models (Opus, Sonnet, etc.).
- **Get a key**: https://console.anthropic.com/settings/keys
- **Models include**: claude-opus-4-6, claude-sonnet-4-5
- **Setting key**: `anthropicApiKey`
### Google Gemini
Access to Google's Gemini family of models.
- **Get a key**: https://makersuite.google.com/app/apikey
- **Models include**: gemini-2.5-pro, gemini-2.5-flash, gemini-3.5-flash, gemini-3.1-pro-preview
- **Setting key**: `googleApiKey`
### XAI / Grok
Access to Grok models from xAI.
- **Get a key**: https://console.x.ai
- **Models include**: grok-4-1-fast
- **Setting key**: `xaiApiKey`
### Groq
Groq provides very fast inference for open-source models.
- **Get a key**: https://console.groq.com/keys
- **Models include**: llama3-8b-8192 (and others)
- **Setting key**: `groqApiKey`
### Mistral
Access to Mistral AI's models.
- **Get a key**: https://console.mistral.ai/api-keys
- **Models include**: mistral-tiny-latest (and others)
- **Setting key**: `mistralApiKey`
### DeepSeek
Access to DeepSeek's chat and reasoning models.
- **Get a key**: https://platform.deepseek.com/api-keys
- **Models include**: deepseek-chat, deepseek-reasoner
- **Setting key**: `deepseekApiKey`
### Cohere
Access to Cohere's Command models.
- **Get a key**: https://dashboard.cohere.ai/api-keys
- **Models include**: command-r
- **Setting key**: `cohereApiKey`
### SiliconFlow
A Chinese AI cloud platform with access to DeepSeek and Qwen models.
- **Get a key**: https://cloud.siliconflow.com/me/account/ak
- **Models include**: DeepSeek-V3, DeepSeek-R1 (via SiliconFlow)
- **Setting key**: `siliconflowApiKey`
### Azure OpenAI
Access to OpenAI models deployed on Microsoft Azure. Requires four fields to be configured:
| Setting | Description |
| --------------- | -------------------------- |
| API Key | Your Azure OpenAI key |
| Instance Name | Your Azure resource name |
| Deployment Name | Your model deployment name |
| API Version | e.g., `2024-02-01` |
- **Note**: Unlike other providers, Azure OpenAI uses your own Azure deployment
- **Embedding**: Can also use Azure for embeddings (separate deployment name required)
### Amazon Bedrock
Access to models hosted on AWS Bedrock.
- **Get credentials**: https://console.aws.amazon.com/iam/home#/security_credentials
- **Required fields**: Access Key ID (API key), Region
- **Setting key**: `amazonBedrockApiKey`
**Important**: Always use cross-region inference profile IDs, not bare model IDs. For example:
- Use: `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
- Not: `anthropic.claude-sonnet-4-5-20250929-v1:0`
Cross-region profiles (with the `us.`, `eu.`, `apac.`, or `global.` prefix) are more reliable and available across regions.
### GitHub Copilot
Use your existing GitHub Copilot subscription to access AI models.
- **OAuth flow**: Click **Connect GitHub Copilot** in the API key dialog
- **No separate API key needed** — authenticates via GitHub OAuth
- **Requires**: Active GitHub Copilot subscription
---
## Local Model Providers
Local providers run models on your own computer. No API key or internet connection needed once set up.
### Ollama
Runs open-source models locally on your machine.
- **Default port**: 11434
- **URL**: `http://localhost:11434/v1/`
- **Setup**: Install Ollama (ollama.ai), pull a model, then add it in Copilot's Model settings
- **No API key required**
### LM Studio
A desktop app for running local models with a GUI.
- **Default port**: 1234
- **URL**: `http://localhost:1234/v1`
- **Setup**: Install LM Studio, load a model, go to the Developer tab, **enable CORS** (required), click "Start Server", then add the model in Copilot
- **No API key required**
### 3rd Party (OpenAI-Format)
For any API that follows the OpenAI API format. Useful for custom deployments, proxies, or other local inference servers (vLLM, LiteLLM, etc.).
- **Requires**: Base URL and optionally an API key
- **Use when**: Your provider isn't in the list but speaks OpenAI-format
> **CORS Warning**: Some third-party providers (e.g., Perplexity) don't support CORS, which causes Copilot to fail with a CORS error. When adding a custom model for such a provider, enable the **CORS** toggle in the custom model form. Note: streaming is not available in CORS mode.
---
## Provider-Specific Gotchas
| Provider | Common Issue | Fix |
| -------------- | ----------------------------------- | -------------------------------------------------------------------------------------- |
| Azure OpenAI | Missing one of four required fields | Check all four settings: key, instance name, deployment name, API version |
| Amazon Bedrock | Rate limit or model not found | Use cross-region inference profile IDs with `us.`, `eu.`, `apac.`, or `global.` prefix |
| GitHub Copilot | Token expired | Re-authenticate via the OAuth button in API key dialog |
| Ollama | Connection refused | Make sure Ollama is running (`ollama serve`) and the port is correct |
| Google Gemini | Quota exceeded | Use a different model or check your quota at console.cloud.google.com |
| DeepSeek | Streaming errors | Try disabling streaming in the per-session settings if you encounter issues |
---
## Related
- [Models and Parameters](models-and-parameters.md) — Enable, disable, and configure models
- [Getting Started](getting-started.md) — First-time setup

471
docs/miyo-api.md Normal file
View file

@ -0,0 +1,471 @@
# Miyo Node Service API
Base URL: `http://127.0.0.1:8742`
All request and response bodies are JSON. Errors always return `{ "detail": "<message>" }`.
---
## Health
### `GET /v0/health`
Returns service and sidecar status.
**Response 200**
```json
{
"status": "ok | degraded",
"service": "running",
"qdrant": "connected | ...",
"llama_server": "running | ...",
"model_download_progress": 0.75,
"embedding_model": "nomic-embed-text-v1.5",
"batch_size_preset": "default",
"gpu_variant": "metal | null",
"indexed_files": 1234
}
```
---
## Search
### `POST /v0/search`
Hybrid semantic + keyword search (dense + BM25, fused via RRF).
**Request body**
```json
{
"query": "string (required)",
"folder_path": "string | null — restrict to this folder",
"path": "string | null — substring filter on file path (case-insensitive)",
"limit": 10,
"filters": [
/* MetadataFilter[], see below */
]
}
```
**Response 200**
```json
{
"results": [
/* SearchResult[] */
],
"query": "string",
"count": 5,
"execution_time_ms": 42.0
}
```
**Errors:** 400 (missing query), 503 (llama-server or Qdrant unavailable)
---
### `POST /v0/search/related`
Find files related to a given file using vector similarity.
**Request body**
```json
{
"file_path": "string (required) — absolute path",
"folder_path": "string | null",
"limit": 10,
"filters": [
/* MetadataFilter[] */
]
}
```
**Response 200**
```json
{
"results": [{ "path": "string", "score": 0.95 }],
"file_path": "string",
"count": 5,
"execution_time_ms": 12.0
}
```
**Errors:** 400, 404 (no indexed chunks for the file), 503
---
## Folders
### `GET /v0/folder`
- With `?path=<folder_path>`: returns a single `FolderEntry` (404 if not registered)
- Without `path`: returns `{ "folders": [ FolderEntry[] ] }`
---
### `POST /v0/folder`
Register a folder for indexing. Starts watching and scanning immediately.
**Request body**
```json
{
"path": "string (required) — absolute path",
"include_patterns": ["**/*.md"],
"exclude_patterns": ["**/node_modules/**"],
"recursive": true
}
```
**Response 201** — `FolderEntry`
**Errors:** 400 (invalid), 409 (already registered)
---
### `PATCH /v0/folder`
Update folder configuration. Only provided fields are changed.
**Request body**
```json
{
"path": "string (required)",
"include_patterns": ["**/*.md"],
"exclude_patterns": ["**/node_modules/**"],
"recursive": false
}
```
**Response 200** — updated `FolderEntry`
**Errors:** 400, 404
---
### `DELETE /v0/folder`
Unregister a folder and remove all its indexed data.
**Request body**
```json
{ "path": "string (required)" }
```
**Response 200** — deletion summary object
**Errors:** 400, 404
---
### `POST /v0/folder/pause`
Stop file watching for a folder without removing it.
**Request body**
```json
{ "path": "string (required)" }
```
**Response 200**
```json
{ "status": "paused", "path": "string" }
```
**Errors:** 400, 404
---
### `POST /v0/folder/resume`
Resume file watching and trigger a rescan.
**Request body**
```json
{ "path": "string (required)" }
```
**Response 202**
```json
{ "status": "scanning", "path": "string" }
```
**Errors:** 400, 404
---
### `POST /v0/scan`
Manually trigger a rescan of a registered folder.
**Request body**
```json
{
"path": "string (required)",
"force": false
}
```
`force: true` re-indexes all files even if unchanged.
**Response 202**
```json
{ "status": "started", "path": "string" }
```
**Errors:** 400, 404
---
## Files & Documents
### `GET /v0/folder/files`
List indexed files with optional filtering and pagination.
**Query parameters**
| Param | Type | Description |
| -------------- | --------------------------------- | ------------------------------- |
| `folder_path` | string | Filter by folder |
| `title` | string | Substring match on title |
| `file_path` | string | Exact file path match |
| `mtime_after` | number | Unix timestamp lower bound |
| `mtime_before` | number | Unix timestamp upper bound |
| `offset` | integer (default 0) | Pagination offset |
| `limit` | integer | Max results (omit for no limit) |
| `order_by` | `mtime` \| `updated_at` (default) | Sort order |
**Response 200**
```json
{
"files": [
/* FileEntry[] */
],
"total": 99
}
```
---
### `GET /v0/folder/documents`
Fetch all indexed chunks for a specific file, sorted by chunk index.
**Query parameters**
| Param | Required | Description |
| ------------- | -------- | -------------------------- |
| `path` | yes | Absolute file path |
| `folder_path` | no | Scope to a specific folder |
**Response 200**
```json
{
"documents": [
/* DocumentChunk[] */
]
}
```
**Errors:** 400, 503
---
## Utilities
### `POST /v0/parse-doc`
Parse a file and return its extracted text content.
**Request body**
```json
{ "path": "string (required) — absolute file path" }
```
**Response 200** — parsed content object (shape varies by file type)
**Errors:**
| Code | Meaning |
| ---- | --------------------- |
| 400 | Invalid input |
| 403 | File not readable |
| 404 | File not found |
| 415 | Unsupported file type |
| 422 | Parse failed |
| 500 | Internal error |
---
### `POST /v0/rebuild-metadata`
Rebuild the manifest by re-syncing metadata from Qdrant. Use when manifest is out of sync.
**Response 200** — `{ "elapsed_ms": 123, ...stats }`
**Errors:** 409 (rebuild already in progress), 503
---
### `POST /v0/llama-server/restart`
Restart the llama-server sidecar, optionally changing the batch size preset.
**Request body**
```json
{ "batch_size_preset": "default" }
```
**Response 200**
```json
{
"restarted": true,
"batch_size_preset": "default",
"status": "running"
}
```
**Errors:** 400
---
### `POST /v1/embeddings`
Generate embeddings. OpenAI-compatible interface, proxied to llama-server.
**Request body**
```json
{
"model": "nomic-embed-text-v1.5",
"input": "string or string[]"
}
```
`model` is optional but must match the configured embedding model if provided.
**Response 200** — standard OpenAI embeddings response
**Errors:** 400, 503
---
## Schemas
### MetadataFilter
Range filter on a metadata field.
```json
{
"field": "mtime",
"gt": 1700000000,
"gte": 1700000000,
"lt": 1800000000,
"lte": 1800000000
}
```
- `field` can be `mtime`, `ctime`, or any metadata key
- Bare field names (not `mtime`/`ctime` and not prefixed with `metadata.`) are automatically prefixed with `metadata.`
- At least one of `gt`, `gte`, `lt`, `lte` must be present
---
### SearchResult
```json
{
"path": "string",
"score": 0.95,
"title": "string | null",
"mtime": 1700000000,
"ctime": 1700000000,
"file_name": "string | null",
"chunk_index": 0,
"total_chunks": 5,
"chunk_text": "string | null",
"metadata": {},
"embedding_model": "string | null",
"tags": ["string"],
"extension": ".md",
"created_at": "string | null",
"nchars": 1024,
"folder_path": "string | null"
}
```
---
### FileEntry
```json
{
"path": "string",
"title": "string | null",
"mtime": 1700000000,
"updated_at": "ISO8601 string",
"folder_path": "string | null",
"total_chunks": 5
}
```
---
### DocumentChunk
```json
{
"id": "string",
"path": "string | null",
"title": "string | null",
"chunk_index": 0,
"chunk_text": "string | null",
"metadata": {},
"embedding_model": "string | null",
"ctime": 1700000000,
"mtime": 1700000000,
"tags": ["string"],
"extension": ".md",
"created_at": "ISO8601 string | null",
"nchars": 1024,
"folder_path": "string | null"
}
```
---
### FolderEntry
Shape varies — includes at minimum:
```json
{
"path": "string",
"include_patterns": ["**/*.md"],
"exclude_patterns": [],
"recursive": true
}
```
Plus live stats fields populated by the folder manager.

View file

@ -0,0 +1,178 @@
# Models and Parameters
This guide explains how to manage chat models, embedding models, and the parameters that control how the AI behaves.
---
## Chat Models
### Built-In Models
Copilot comes with a set of built-in models across many providers. Some are always included ("core" models); others can be enabled or disabled.
| Model | Provider | Capabilities |
| ----------------------------- | ------------ | ----------------------- |
| copilot-plus-flash | Copilot Plus | Vision (Plus exclusive) |
| google/gemini-2.5-flash | OpenRouter | Vision |
| google/gemini-2.5-pro | OpenRouter | Vision |
| google/gemini-3.5-flash | OpenRouter | Vision, Reasoning |
| google/gemini-3.1-pro-preview | OpenRouter | Vision, Reasoning |
| openai/gpt-5.4 | OpenRouter | Vision |
| openai/gpt-5-mini | OpenRouter | Vision |
| gpt-5.4 | OpenAI | Vision |
| gpt-5-mini | OpenAI | Vision |
| gpt-4.1 | OpenAI | Vision |
| gpt-4.1-mini | OpenAI | Vision |
| claude-opus-4-6 | Anthropic | Vision, Reasoning |
| claude-sonnet-4-5-20250929 | Anthropic | Vision, Reasoning |
| gemini-2.5-pro | Google | Vision |
| gemini-2.5-flash | Google | Vision |
| gemini-3.5-flash | Google | Vision, Reasoning |
| grok-4-1-fast | XAI | Vision |
| deepseek-chat | DeepSeek | — |
| deepseek-reasoner | DeepSeek | Reasoning |
### Model Capability Badges
Models may show capability badges:
- **Reasoning** — Extended internal thinking before responding; better for complex tasks
- **Vision** — Can process images (e.g., screenshots, diagrams embedded in notes)
- **Web Search** — Can access the internet directly (model-native feature)
### Managing Models
Go to **Settings → Copilot → Model** to see the full model list.
- **Enable/disable** — Toggle individual models on or off to control what appears in the model selector
- **Reorder** — Drag models to change their order in the dropdown
- **Delete** — Remove custom models you've added
### Adding Custom Models
If your provider offers a model that isn't in the built-in list, you can add it manually:
1. Go to **Settings → Copilot → Model**
2. Click **Add Model**
3. Enter the model name exactly as the provider expects it (e.g., `gpt-4-turbo-preview`)
4. Select the provider
5. Optionally set a custom base URL (useful for proxies or alternate endpoints)
6. Save
### Importing Models from Provider
You can automatically import the full list of available models from a provider:
1. Go to **Settings → Copilot → Model**
2. Find the **Import models** button for your provider
3. Copilot will fetch the provider's model list and add new ones
---
## Embedding Models
Embedding models convert text into numerical vectors, which powers semantic (meaning-based) search in Vault QA and the "Relevant Notes" feature.
### Built-In Embedding Models
| Model | Provider |
| ----------------------------- | --------------------------------- |
| copilot-plus-small | Copilot Plus (Plus exclusive) |
| copilot-plus-large | Copilot Plus (Believer exclusive) |
| copilot-plus-multilingual | Copilot Plus (Plus exclusive) |
| openai/text-embedding-3-small | OpenRouter |
| text-embedding-3-small | OpenAI |
| text-embedding-3-large | OpenAI |
| embed-multilingual-light-v3.0 | Cohere |
| text-embedding-004 | Google |
| gemini-embedding-001 | Google |
| Qwen3-Embedding-0.6B | SiliconFlow |
### Selecting an Embedding Model
Go to **Settings → Copilot → QA****Embedding Model**.
If you change embedding models, you must rebuild the vault index because the old vectors are incompatible with the new model. Copilot will prompt you to confirm before rebuilding.
### What Embeddings Affect
- **Vault QA mode** — Uses embeddings to find relevant notes by meaning
- **Semantic Search** — The "Enable Semantic Search" toggle in QA settings
- **Relevant Notes** — Shows semantically similar notes in the sidebar
---
## Model Parameters
These settings control how the AI responds. Global defaults live in Settings → Copilot → Model. You can override them per-session using the gear icon in the chat panel.
### Temperature
Controls how random or creative the responses are.
- **Range**: 0.01.0
- **Default**: 0.1
- **Low (0.00.2)**: Precise, factual, deterministic
- **Medium (0.40.6)**: Balanced
- **High (0.81.0)**: Creative, varied, less predictable
### Max Tokens
Maximum number of tokens in the AI's response. A **token** is roughly ¾ of a word (so 1,000 tokens ≈ 750 words).
- **Default**: 6,000
- Higher values allow longer responses but cost more
### Conversation Turns in Context
How many past conversation turns to include in each request. More turns = more context but larger requests.
- **Default**: 15 turns
- Reduce this if you hit context limits or want to lower costs
### Auto-Compact Threshold
When the conversation reaches this many tokens, older messages are automatically summarized.
- **Default**: 128,000 tokens
- **Range**: 64,0001,000,000 tokens
- See [Chat Interface](chat-interface.md#auto-compact) for details
### Reasoning Effort
For reasoning-capable models (like deepseek-reasoner, claude-opus-4-6), controls how much internal reasoning the model does before responding.
- **Options**: minimal, low, medium, high, xhigh
- **Default**: low
- Higher effort = better results on complex tasks, slower responses
### Verbosity
For models that support it, controls response length and detail.
- **Options**: low, medium, high
- **Default**: medium
### Top P
An alternative to temperature for controlling randomness. Leave at default unless you have a specific reason to change it.
### Frequency Penalty
Reduces the likelihood of the model repeating itself.
---
## Default Model Selection
Your **default model** is the one Copilot uses when you open a new chat. Set it in:
**Settings → Copilot → Basic → Default Chat Model**
The default is **OpenRouter Gemini 2.5 Flash** (requires OpenRouter API key).
---
## Related
- [LLM Providers](llm-providers.md) — Set up API keys for your provider
- [Vault Search and Indexing](vault-search-and-indexing.md) — How embedding models are used

125
docs/projects.md Normal file
View file

@ -0,0 +1,125 @@
# Projects
Projects are focused AI workspaces. Each project has its own model, system prompt, context sources, and completely isolated chat history. Use projects to keep separate AI conversations per client, topic, or area of work.
Projects support **50+ file types** beyond markdown, including PDFs, Word documents, PowerPoint, Excel, images, and more — making them ideal for analyzing large or diverse document collections.
> **Note**: Projects is an alpha feature. It may have rough edges and is subject to change.
---
## Overview
In regular chat, all conversations share the same settings and model. Projects let you create dedicated workspaces with:
- **A specific context** — Specific notes, folders, URLs, or YouTube videos the AI always has access to
- **A dedicated model** — Different projects can use different AI models
- **A custom system prompt** — Each project can have its own instructions for the AI
- **Isolated chat history** — Conversations in one project don't mix with conversations in another
**Example use cases:**
- A "Research" project that always has your research notes as context
- A "Client Work" project with a specific system prompt and access to client-related notes
- A "Learning" project with YouTube video URLs for study materials
---
## Creating a Project
1. Open the chat panel
2. Click the mode selector at the top of the chat
3. Select **Projects (alpha)**
4. Click **New Project** (or the `+` button)
5. Fill in the project details and save
---
## Project Configuration
Each project has the following settings:
### Name
A short name for the project. Appears in the project list.
### Description
An optional description of what the project is for.
### Model
Choose which AI model to use for this project. The available options depend on which models you have enabled.
### Model Settings
Override the default temperature and max tokens specifically for this project.
### System Prompt
Set a custom system prompt for this project. This replaces (or supplements) the global default. See [System Prompts](system-prompts.md) for details.
---
## Context Sources
Projects let you pre-load context that is always available in the project's chat.
### File Inclusions and Exclusions
Specify which notes or folders to include in this project's context:
- **Inclusions**: Only these notes/folders are available for search and context
- **Exclusions**: These notes/folders are excluded from context
This scopes the AI's knowledge to just the notes relevant to your project.
### Web URLs
Add web page URLs that are fetched and included as context for every conversation in this project. Useful for documentation, reference pages, or web resources you frequently consult.
### YouTube URLs
Add YouTube video URLs whose transcripts are loaded into context for every conversation.
---
## Working in a Project
### Switching Projects
Use the project selector at the top of the chat panel to switch between projects. When you switch, the chat history clears and the new project's context loads.
### Isolated Chat History
Each project maintains its own chat history, completely separate from other projects and from regular (non-project) chat. Conversations don't bleed across projects.
### Context Loading
When you open a project, Copilot loads the configured context (notes, URLs, etc.) automatically. For large projects with many notes, this may take a moment.
---
## Project List Management
Go to the project selector to manage your projects:
- **Sort**: Projects can be sorted by most recently used or alphabetically
- **Edit**: Click the edit icon to change a project's settings
- **Delete**: Remove the project entry from the list (saved conversation files in your vault are not deleted)
Sort strategy: **Settings → Copilot → Basic → Project list sort strategy**
---
## Limitations
As an alpha feature, projects have some known limitations:
- Large context sources (many notes or large files) may slow down context loading
- The context loading on project switch is synchronous — the AI isn't available until loading completes
- Some features available in regular Plus mode may behave differently in projects
- Auto-compact behavior is the same as regular chat
---
## Related
- [Chat Interface](chat-interface.md) — Chat modes overview, new chat behavior, history
- [System Prompts](system-prompts.md) — Custom system prompts for projects
- [Context and Mentions](context-and-mentions.md) — How context works
- [Copilot Plus and Self-Host](copilot-plus-and-self-host.md) — Plus features

126
docs/system-prompts.md Normal file
View file

@ -0,0 +1,126 @@
# System Prompts
A system prompt is a set of instructions you give the AI that shapes how it behaves in all conversations. Think of it as a persistent briefing: "You are an assistant that helps me with academic writing. Always cite sources. Respond in formal English."
---
## Overview
Copilot has two layers of system prompts:
1. **Built-in system prompt** — Always active. Defines core behaviors specific to Obsidian (how to format Obsidian links, how to handle note references, etc.)
2. **Custom system prompt** — Optional. You can write your own instructions that are appended to the built-in prompt.
---
## Built-In System Prompt
The built-in prompt is always active and cannot be edited. It tells the AI:
- It is "Obsidian Copilot" — an AI integrated into Obsidian
- How to format Obsidian internal links: `[[Note Title]]`
- How to format Obsidian image links: `![[image.png]]`
- How to format LaTeX math: use `$...$` not `\[...\]`
- How to handle @vault and @tool mentions
- To use `-` for bullet points (not `*`)
- To respond in the language of the user's query
- To treat "note" as referring to an Obsidian note
This prompt ensures Copilot's output is correctly formatted for Obsidian and aware of its context.
> **Warning**: Disabling the built-in prompt can break features like Vault QA, memory, and agent tools. Avoid disabling it unless you have a specific reason.
---
## Custom System Prompts
Custom system prompts let you add your own instructions on top of the built-in prompt.
### Where They're Stored
Custom system prompts are stored as markdown files in your vault, in the folder:
```
copilot/system-prompts/
```
You can change this folder in **Settings → Copilot → Advanced → System Prompts Folder Name**.
### Creating a System Prompt
#### From Settings
1. Go to **Settings → Copilot → Advanced**
2. Under **User System Prompt**, click the `+` button
3. Enter a title for the prompt (e.g., "Academic Writing")
4. A new markdown file is created in your system prompts folder
5. Open the file and write your instructions
#### From the System Prompts Folder
Create any `.md` file in the `copilot/system-prompts/` folder. Its filename (without `.md`) becomes the prompt's title.
### Writing Good System Prompts
Tips for effective system prompts:
- **Be specific**: "Always respond in bullet points with no more than 5 bullets" is better than "be concise"
- **Set a persona**: "You are an expert in cognitive science helping me build a Zettelkasten"
- **Define output format**: Specify if you want headers, lists, prose, or code blocks
- **Set language**: "Always respond in French" if you want non-English output
- **Limit scope**: "Only answer questions related to my research notes on climate science"
**Example system prompt:**
```markdown
You are a Zettelkasten assistant helping me build a knowledge base.
- Always connect new ideas to existing notes when possible
- Suggest up to 3 related concepts per response
- Format all note suggestions as [[Note Title]]
- Keep responses concise — under 200 words
```
---
## Setting a Global Default
You can set one of your custom prompts as the global default — it will be used for all new chat sessions:
1. Go to **Settings → Copilot → Advanced**
2. Under **Default System Prompt**, select your prompt from the dropdown
3. Any new conversation will start with this prompt active
To stop using a custom default, select **None (use built-in prompt)** from the dropdown.
---
## Per-Session Override (Gear Icon)
You can override the system prompt for just the current conversation:
1. Click the **gear icon** in the chat panel toolbar
2. Select a different system prompt (or type a one-off prompt directly)
3. This applies to the current session only and resets when you start a new chat
---
## How Prompts Combine
When you have a custom prompt active:
1. The built-in Copilot prompt runs first
2. Your custom prompt is appended after it
Both sets of instructions are active simultaneously. Your custom instructions can refine, restrict, or extend the default behavior, but they don't replace it.
---
## Per-Project System Prompts
Each [Project](projects.md) can have its own system prompt, independent of the global default. Configure this in the project settings under **System Prompt**.
---
## Related
- [Chat Interface](chat-interface.md) — Per-session gear settings
- [Projects](projects.md) — Per-project system prompts
- [Getting Started](getting-started.md) — Initial setup

View file

@ -0,0 +1,307 @@
# Troubleshooting and FAQ
This guide covers common errors, provider-specific issues, performance problems, and frequently asked questions.
---
## First Steps for Any Issue
Before diving into specific fixes, try these steps first:
1. **Check you're on the latest version** of Copilot in Community Plugins
2. **Disable other plugins** temporarily to rule out conflicts
3. **Enable Debug Mode** in Settings → Copilot → Advanced → Debug Mode
4. **Open the developer console**: `Cmd+Option+I` on Mac, `Ctrl+Shift+I` on Windows
---
## Common Errors
### "API key not set" or "No API key configured"
**Cause**: The model you selected doesn't have a valid API key for its provider.
**Fix**:
1. Go to **Settings → Copilot → Basic → Set Keys**
2. Enter the API key for the provider your model uses
3. If you're unsure which provider a model uses, check **Settings → Copilot → Model** — each model shows its provider
### Rate Limit Errors
**Cause**: You've sent too many requests to the API in a short time.
**Fix**:
- Wait a minute and try again
- If this happens frequently during indexing, reduce **Embedding Requests per Minute** in QA settings (try 1020)
- Consider upgrading your API plan with the provider
### Connection Errors / Timeout
**Cause**: Network issue, provider outage, or the request took too long.
**Fix**:
- Check your internet connection
- Try again after a few seconds
- Check the provider's status page for outages
- If using a local model (Ollama/LM Studio), make sure the local server is running
### "Copilot index does not exist"
**Cause**: You're trying to use Vault QA or semantic search but the vault hasn't been indexed yet.
**Fix**:
1. Make sure you have an embedding model configured with a valid API key (**Settings → Copilot → QA → Embedding Model**)
2. Run **Command palette → Index (refresh) vault**
3. Wait for indexing to complete
### "RangeError: invalid string length"
**Cause**: Your vault is too large for a single index partition.
**Fix**: Increase the number of partitions in **Settings → Copilot → QA → Partitions**. A good target is keeping the first index file under ~400 MB (check the `.obsidian/` folder for `copilot-index` files and their sizes).
### Response Gets Cut Off
**Cause**: The AI's response hit the Max Tokens limit.
**Fix**: Increase **Max Tokens** in Settings → Copilot → Model (or the per-session gear icon). Default is 6,000 tokens.
### Notes Not Found in Search
Even after indexing, relevant notes aren't being returned? Try:
1. Switch to **Copilot Plus** mode and use `@vault` for more powerful search
2. Try the **multilingual embedding model** for non-English notes
3. Review your QA inclusions/exclusions to confirm the notes aren't filtered out
4. Run **List all indexed files** (debug command) to verify the notes are indexed
5. Run **Force reindex vault** for a clean rebuild
### "Non-markdown files are only available in Copilot Plus"
**Cause**: You tried to use a PDF, image, or other non-markdown file as context in a free mode.
**Fix**: Switch to Copilot Plus mode, or convert the file to markdown manually.
---
## Provider-Specific Issues
### Ollama
**Problem**: "Connection refused" or model not responding
**Fix**:
- Make sure Ollama is running: open a terminal and run `ollama serve`
- Verify the model is downloaded: `ollama list`
- Check that the port in Copilot settings matches (default: 11434)
- On some systems, Ollama uses `http://127.0.0.1:11434` instead of `http://localhost:11434` — try both
### Azure OpenAI
**Problem**: Authentication errors or model not found
**Fix**:
Azure OpenAI requires all four fields to be filled in correctly:
1. API Key
2. Instance Name (your Azure resource name, e.g., `my-azure-openai`)
3. Deployment Name (the name you gave your model deployment)
4. API Version (e.g., `2024-02-01`)
Any missing or incorrect field will cause errors.
### Amazon Bedrock
**Problem**: "Model not found" or access denied
**Fix**:
- Always use **cross-region inference profile IDs**, not bare model IDs:
- ✅ `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
- ❌ `anthropic.claude-sonnet-4-5-20250929-v1:0`
- Make sure your IAM credentials have Bedrock access permissions
- Confirm the model is available in your region
### GitHub Copilot
**Problem**: "Token expired" or authentication fails
**Fix**:
- Go to **Settings → Copilot → Basic → Set Keys**
- Click **Connect GitHub Copilot** to re-authenticate via OAuth
- Make sure your GitHub Copilot subscription is active
### Google Gemini
**Problem**: "QUOTA_EXCEEDED" or slow responses
**Fix**:
- Check your quota at https://console.cloud.google.com
- Try switching to the Flash model (faster, higher quota)
- Consider using Google via OpenRouter instead for a unified quota
### DeepSeek
**Problem**: Response cuts off or streaming errors
**Fix**:
- DeepSeek reasoning models (deepseek-reasoner) can produce very long outputs; try increasing Max Tokens
- If you see streaming errors, check the DeepSeek status page
- Try switching between deepseek-chat and deepseek-reasoner
---
## Performance Issues
### Slow Indexing
**Cause**: Large vault with many notes, or low rate limit setting.
**Fix**:
- Check **Embedding Requests per Minute** — higher values speed up indexing but may cause rate limits
- Use exclusions to skip folders you don't need indexed (e.g., large archive folders)
- Use the incremental **Index (refresh) vault** command instead of Force Reindex when possible
- Consider Miyo (self-host) for local indexing without API rate limits
### High Memory Usage
**Cause**: Large lexical search index or many indexed files.
**Fix**:
- Reduce **Lexical Search RAM Limit** in QA settings (default 100 MB, range 201000 MB)
- Add more folders to exclusions to reduce the index size
- On mobile, disable indexing altogether
### UI Lag
**Cause**: Rendering many chat messages or a very long conversation.
**Fix**:
- Start a new chat — long conversations can slow down rendering
- Auto-compact will trigger automatically at 128,000 tokens to keep conversations manageable
- Lower your auto-compact threshold if you're hitting performance issues early
---
## Settings Issues
### Reset Settings to Default
If your settings get into a bad state, you can reset:
1. Go to **Settings → Copilot** → find the reset option
2. Or delete the `data.json` file from the plugin folder: `.obsidian/plugins/copilot/data.json`
⚠️ Resetting clears all your settings. API keys kept in `data.json` (standard storage) are removed, but keys stored in the Obsidian Keychain are **not** — to erase those, use **Settings → Copilot → Advanced → API Key Storage → Delete All Keys**. Back up your keys first.
### API Key Storage
Copilot has two ways to store API keys:
- **Standard storage**: API keys are saved in `data.json` in plain text. Existing vaults stay in this mode until you choose to migrate.
- **Obsidian Keychain**: New installs use this by default. You can also switch an existing vault by going to **Settings → Copilot → Advanced → API Key Storage** and clicking **Migrate to Obsidian Keychain**. After migration, `data.json` no longer contains your API keys.
The Obsidian Keychain is per device. If you sync your vault to another device, you may need to re-enter API keys there.
### Debug Mode and Logs
For reporting bugs:
1. **Enable Debug Mode**: **Settings → Copilot → Advanced → Debug Mode**
2. **Create a log file**: **Settings → Copilot → Advanced → Create Log File**
3. The log file opens in your vault — attach it to your bug report
---
## Frequently Asked Questions
### Is my data private? Does Copilot send my notes to the cloud?
Copilot itself doesn't store your notes on any server. However, when you send a message, the content (including any context from your notes) is sent to the AI provider you've configured (OpenAI, Anthropic, etc.) via their API. Each provider has its own privacy policy. Your notes are not sent anywhere until you actively use the chat.
The memory system stores data in your vault locally. Chat history is saved as markdown files in your vault. Nothing is stored on Copilot's servers unless you use Copilot Plus cloud features.
**For maximum privacy**: Google Gemini's paid API (the basis for copilot-plus-flash) does not use API request data to train its models. For complete local privacy, consider using Ollama or LM Studio with a local model — nothing leaves your machine. Self-host mode is available now for lifetime license holders — see [Copilot Plus and Self-Host](copilot-plus-and-self-host.md) for details.
### Can I reference a specific note in chat?
Yes — use `[[Note Title]]` syntax directly in your message. Copilot adds that note's content as context in the background. You can also use @-mentions. See [Context and Mentions](context-and-mentions.md) for the full list of ways to add context.
### How do I make Copilot always reply in English?
Go to **Settings → Copilot → Advanced → Default System Prompt**, create a custom prompt, and add "Always respond in English." as an instruction. See [System Prompts](system-prompts.md).
### Can Copilot understand images in my notes?
Yes, but only with models that have **Vision** capability (shown by a vision icon in the model list). Make sure:
1. You're using a vision-capable model
2. **Settings → Copilot → Basic → Pass markdown images to AI** is enabled
### Why can't Copilot read my PDF?
- Large PDFs (over 10 MB) should be converted to markdown first
- In Copilot Plus mode, use **+ Add context** to attach a PDF — it will be converted automatically
- For large PDF collections, **Projects mode** is better suited (supports PDF as context natively)
### Can I use Copilot offline?
With local models (Ollama or LM Studio), yes — once a model is downloaded, it runs fully offline. Cloud providers (OpenAI, Anthropic, etc.) require an internet connection.
Lexical vault search works offline. Semantic search requires an embedding model, which may also need an internet connection unless you're using a local embedding provider or Miyo.
### What's the difference between Chat mode and Vault QA mode?
- **Chat** — General conversation. The AI only has access to your current note and anything you explicitly mention.
- **Vault QA** — Specifically designed for asking questions about your vault. Copilot automatically searches your notes for relevant content and includes it as context.
For most question-and-answer tasks over your vault, use **Vault QA** or **Copilot Plus** mode.
### Can I use multiple providers at the same time?
Yes. You can have API keys configured for multiple providers simultaneously and switch between models from different providers at any time. You can even set a different model for quick commands vs. regular chat.
### Where are my saved chats stored?
Chat conversations are saved as markdown files in your vault, in the folder `copilot/copilot-conversations/` by default. You can change this folder in **Settings → Copilot → Basic → Default save folder**.
### How do I clear the Copilot cache?
Use **Command palette → Clear Copilot cache**. This clears cached responses and processed files. It does not affect your chat history or the vault index.
### What is the `copilot/` folder in my vault?
The `copilot/` folder is created by the plugin and stores:
- `copilot-conversations/` — Saved chat histories
- `copilot-custom-prompts/` — Your custom commands
- `system-prompts/` — Your custom system prompts
- `memory/` — Saved AI memories (if enabled)
This folder is automatically excluded from vault search to avoid cluttering results.
### How do I switch modes?
Click the mode selector at the top of the chat panel. Available modes:
- Chat
- Vault QA (Basic)
- Copilot Plus (requires license)
- Projects (alpha)
### The AI keeps forgetting what we talked about earlier
This usually means the conversation has grown too long and older turns are being trimmed from context. Options:
- Lower **Conversation Turns in Context** in Model settings
- Let auto-compact handle it (it summarizes old turns automatically)
- Start a new chat and reference the previous chat file
---
## Getting More Help
- **GitHub Issues**: Report bugs at https://github.com/logancyang/obsidian-copilot/issues
- **Discord**: Join the Copilot Discord community for help from other users
- **Log file**: Create a log file (**Settings → Copilot → Advanced → Create Log File**) and include it in bug reports
---
## Related
- [Getting Started](getting-started.md) — First-time setup
- [LLM Providers](llm-providers.md) — Provider-specific setup details
- [Vault Search and Indexing](vault-search-and-indexing.md) — Index management

View file

@ -0,0 +1,181 @@
# Vault Search and Indexing
Copilot can search your vault to find relevant notes and answer questions grounded in your own content. This guide explains the two types of search, how to manage the index, and how to configure what gets indexed.
---
## Two Types of Search
### Lexical Search (Keyword-Based)
Lexical search finds notes that contain the exact words you used. It's fast, requires no setup, and works out of the box.
- **Used in**: Vault QA (Basic) mode
- **How it works**: Looks for your exact keywords in note titles and content
- **Strengths**: Fast, precise, no embedding API calls needed
- **Limitations**: Won't find notes that use different words to express the same idea
**RAM Limit**: The lexical search index is held in memory. You can configure the memory limit in **Settings → Copilot → QA → Lexical Search RAM Limit** (default: 100 MB, range: 201,000 MB).
**Lexical Boosts**: Copilot can boost search results from notes in the same folder as the current note, or from notes that link to each other. Enable in **Settings → Copilot → QA → Enable Lexical Boosts** (on by default).
### Semantic Search (Meaning-Based)
Semantic search finds notes that are conceptually related, even if they don't share exact words.
- **Used in**: Vault QA and Copilot Plus modes — but **disabled by default**. You must explicitly enable it.
- **How it works**: Converts your notes into numerical vectors (using an embedding model), then finds notes whose vectors are closest to your query
- **Strengths**: Finds notes by concept and meaning, great for "fuzzy" recall
- **Cost**: Requires embedding API calls (costs money for paid embedding models)
- **Enable**: **Settings → Copilot → QA → Enable Semantic Search** — turn this on to activate semantic search
---
## Index Management
The semantic search index stores the vector embeddings of your notes. Manage it from **Settings → Copilot → QA**.
### Auto-Index Strategy
Controls when Copilot automatically updates the index:
| Strategy | When the index updates |
|---|---|
| **NEVER** | Manual only — you must trigger indexing yourself |
| **ON STARTUP** | Updates when Obsidian starts or the plugin reloads |
| **ON MODE SWITCH** | Updates when you switch to Vault QA or Copilot Plus mode (Recommended) |
The default is **ON MODE SWITCH**.
> **Warning**: For large vaults using paid embedding models, frequent indexing can incur significant costs. Consider using NEVER and indexing manually if cost is a concern.
### Refresh Index (Incremental)
**Command palette → Index (refresh) vault**
Updates only notes that have been added, modified, or deleted since the last index. Faster and cheaper than a full reindex.
### Force Reindex
**Command palette → Force reindex vault**
Rebuilds the entire index from scratch. Use this if:
- You changed your embedding model
- The index seems corrupted or missing results
- You've made many changes and want a clean state
### Garbage Collection
**Command palette → Garbage collect Copilot index (remove files that no longer exist in vault)**
Removes entries from the index for notes that have been deleted from your vault. Keeps the index clean without a full reindex.
### Clear Index
**Command palette → Clear local Copilot index**
Deletes the entire index. You'll need to reindex before semantic search works again.
### Debug Commands
For troubleshooting:
- **List indexed files** — Shows all notes currently in the index
- **Inspect index by note paths** — Check which chunks of specific notes are indexed
- **Count total vault tokens** — Estimates total tokens across your vault
- **Search semantic index** — Run a direct search query against the index
---
## Filtering: What Gets Indexed
Control which notes are included in semantic search.
### Cost Estimation Before Indexing
Before indexing a large vault with a paid embedding model, estimate the cost first:
**Command palette → Count total tokens in your vault**
This shows the total token count across your vault, which you can use to estimate embedding API costs. Embedding costs are generally low, but worth checking for very large vaults.
### Exclusions
**Settings → Copilot → QA → Exclusions**
Comma-separated list of patterns. Notes matching these patterns are excluded. Supports:
- Folder names: `private` — excludes the folder named "private"
- Folder paths: `Work/Confidential` — excludes that specific subfolder
- File extensions: `.pdf` — excludes all PDF files
- Tags: `#private` — excludes all notes tagged `#private`
- Note titles: `My Secret Note` — excludes that specific note
Example: `private, Work/Confidential, #private` excludes the private folder, a specific work folder, and all notes tagged #private.
> **Note**: Tag matching works with tags in the note's **properties (frontmatter)**, not inline tags within the note body.
The `copilot` folder is always excluded automatically (it contains the plugin's own files).
### Inclusions
**Settings → Copilot → QA → Inclusions**
Comma-separated list. If set, **only** notes matching these patterns are indexed. Useful for indexing a specific area of your vault.
Leave empty to include everything (except exclusions).
---
## Embedding Settings
These settings appear in **Settings → Copilot → QA** when Semantic Search is enabled.
### Requests per Minute
How many embedding API requests to send per minute. Default is 60. Decrease this if you hit rate limit errors from your embedding provider.
Range: 1060
### Embedding Batch Size
How many text chunks to send per API request. Default is 16. Larger batches are faster but may cause issues with some providers.
### Partitions
The index is split into partitions to handle large vaults. You can control the number of partitions in **Settings → Copilot → QA → Number of Partitions**. If you have a large vault, increase this value to avoid index errors.
> **If you hit a "RangeError: invalid string length" error**: This means your vault is too large for a single partition. Increase the number of partitions in QA settings. A good rule of thumb is that the first partition file (found in `.obsidian/`) should be under ~400 MB.
---
## Inline Citations (Experimental)
When enabled, AI responses in Vault QA include footnote-style citations pointing to the source notes used in the answer.
**Enable**: **Settings → Copilot → QA → Enable Inline Citations**
This is an experimental feature. Not all models handle it well.
---
## Obsidian Sync
If you use Obsidian Sync, the vector index can be synced across devices. Enable **Settings → Copilot → QA → Enable Index Sync**.
> **Note**: The index can be large (hundreds of MB for big vaults). Keep this in mind for sync limits and mobile data usage.
---
## Mobile Considerations
By default, Copilot **disables indexing on mobile** to save battery and data. The setting is in **Settings → Copilot → QA → Disable index on mobile** (on by default).
On mobile, you can still use Vault QA with lexical search, but semantic search won't update automatically.
---
## Related
- [Agent Mode and Tools](agent-mode-and-tools.md) — How @vault uses the index in Plus mode
- [Models and Parameters](models-and-parameters.md) — Choosing an embedding model
- [Copilot Plus and Self-Host](copilot-plus-and-self-host.md) — Miyo-powered local semantic search

View file

@ -2,11 +2,19 @@ import esbuild from "esbuild";
import svgPlugin from "esbuild-plugin-svg";
import process from "process";
import wasmPlugin from "./wasmPlugin.mjs";
import nodeModuleShim from "./nodeModuleShim.mjs";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
// Polyfill for import.meta in CommonJS context
if (typeof import_meta === 'undefined') {
var import_meta = {
url: typeof __filename !== 'undefined' ? 'file://' + __filename : 'file:///obsidian-plugin'
};
}
`;
const prod = process.argv[2] === "production";
@ -31,6 +39,14 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
// Node.js built-in modules (available in Electron) - except node:module which we shim
"node:fs",
"node:path",
"node:url",
"node:buffer",
"node:stream",
"node:crypto",
"node:async_hooks",
],
format: "cjs",
target: "es2020",
@ -38,10 +54,11 @@ const context = await esbuild.context({
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
plugins: [svgPlugin(), wasmPlugin],
plugins: [nodeModuleShim, svgPlugin(), wasmPlugin],
define: {
global: "window",
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
"import.meta.url": "import_meta.url",
},
minify: prod,
});

279
eslint.config.mjs Normal file
View file

@ -0,0 +1,279 @@
import obsidianmd from "eslint-plugin-obsidianmd";
import eslintReact from "@eslint-react/eslint-plugin";
import reactHooks from "eslint-plugin-react-hooks";
import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals";
export default [
{
ignores: [
"node_modules/**",
"main.js",
"styles.css",
"data.json",
"designdocs/**",
"docs/**",
],
},
// obsidianmd recommended brings:
// - eslint:recommended
// - typescript-eslint recommendedTypeChecked on .ts/.tsx (recommended on .js/.jsx)
// - obsidianmd plugin + all obsidianmd-namespaced rules
// - import / @microsoft/sdl / depend / no-unsanitized
// - Obsidian-injected globals (activeDocument, createDiv, etc.)
...obsidianmd.configs.recommended,
// React + tailwind plugins ship flat configs with no `files` filter, so
// they'd cascade onto package.json (which uses the JSON parser) and crash.
// Constrain them to JSX/TSX sources where React/JSX rules actually apply.
{
files: ["**/*.{jsx,tsx}"],
...eslintReact.configs.recommended,
},
{
files: ["**/*.{jsx,tsx}"],
rules: {
// Deferred to follow-up PRs — these flag legitimate anti-patterns but
// each fix requires per-component intent analysis, and they're surfaced
// as warnings (not errors) so they don't block CI.
//
// no-direct-set-state-in-use-effect: ~50 violations. Common pattern is
// "sync local state with prop", which has no one-size-fits-all fix —
// some cases want render-time derivation, others want a `key` prop reset
// or `useSyncExternalStore`. Refactoring blindly risks behavior regressions
// in the chat UI's stateful components.
"@eslint-react/hooks-extra/no-direct-set-state-in-use-effect": "warn",
},
},
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
plugins: { "react-hooks": reactHooks },
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
},
},
...tailwind.configs["flat/recommended"].map((cfg) => ({
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
...cfg,
})),
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
languageOptions: {
globals: {
// Obsidian plugin runtime injects `app` as a global (see CLAUDE.md).
app: "readonly",
},
},
settings: {
"react-x": { version: "detect" },
tailwindcss: {
callees: ["classnames", "clsx", "ctl", "cn", "cva"],
config: "./tailwind.config.js",
cssFiles: ["**/*.css", "!**/node_modules", "!**/.*", "!**/dist", "!**/build"],
// Obsidian-provided utility classes used in JSX but not defined in our CSS.
whitelist: ["clickable-icon"],
},
},
rules: {
// Carry-over from legacy .eslintrc
"no-prototype-builtins": "off",
"tailwindcss/classnames-order": "error",
"tailwindcss/enforces-negative-arbitrary-values": "error",
"tailwindcss/enforces-shorthand": "error",
"tailwindcss/migration-from-tailwind-2": "error",
"tailwindcss/no-arbitrary-value": "off",
"tailwindcss/no-custom-classname": "error",
"tailwindcss/no-contradicting-classname": "error",
// obsidianmd: defer to follow-up PRs
"obsidianmd/ui/sentence-case": "off",
// obsidianmd: disabled intentionally — Platform.isMacOS branching is on-purpose
"obsidianmd/platform": "off",
// Bundled by obsidianmd/recommended via tseslint.configs.recommendedTypeChecked.
// Disabled here because the codebase intentionally uses `any` / dynamic typing
// around Obsidian's untyped APIs and LangChain message shapes — flipping these
// on would require refactoring thousands of call sites with no functional gain.
//
// Violation counts (src/**/*.{ts,tsx}) are noted inline. Rules with low counts
// are candidates to enable in small follow-up PRs.
// --- Heavy: any-flow through Obsidian/LangChain APIs ---
// no-unsafe-member-access: enabled globally; tests are exempted via the
// test-file override below.
"@typescript-eslint/no-unsafe-assignment": "off", // enabled for tests below; follow-up PR for production
"@typescript-eslint/no-unsafe-call": "off", // 107 violations
// --- Medium: promise / method ergonomics ---
// Enabled in the TS-only block below.
// no-deprecated: defer — surface the warnings, but don't fail CI yet
"@typescript-eslint/no-deprecated": "off",
// SDL / import / no-unsanitized / depend: defer — review separately
"no-restricted-globals": "off",
},
},
// Guardrail: every standalone React root in the plugin must go through
// `createPluginRoot` so descendants can rely on `useApp()` unconditionally
// (the bug class fixed in PR #2466). Forbid importing `createRoot` from
// `react-dom/client` anywhere except the helper itself.
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/utils/react/createPluginRoot.tsx"],
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"ImportDeclaration[source.value='react-dom/client'] ImportSpecifier[imported.name='createRoot']",
message:
"Use createPluginRoot from '@/utils/react/createPluginRoot' instead. It wraps the root in <AppContext.Provider> so descendants can rely on useApp() unconditionally (see PR #2466).",
},
],
},
},
// Test files need Jest globals
{
files: ["**/*.test.{js,jsx,ts,tsx}", "jest.setup.js", "__mocks__/**"],
languageOptions: {
globals: {
...globals.jest,
...globals.node,
},
},
rules: {
"import/no-nodejs-modules": "off",
// Tests use intentional `any` mocks; disable type-safety rules that flood
// the test suite without adding signal.
"@typescript-eslint/no-unsafe-member-access": "off",
},
},
// Tests have been cleaned of unsafe `any` assignments. Production code
// (~499 violations) is a follow-up; keep tests enforced.
{
files: ["**/*.test.{ts,tsx}"],
rules: {
"@typescript-eslint/no-unsafe-assignment": "error",
},
},
// Integration tests bootstrap jsdom fetch via `node-fetch` polyfill —
// allow the otherwise-banned import here only.
{
files: ["src/integration_tests/**"],
rules: {
"no-restricted-imports": "off",
},
},
// Node-context files (build configs, scripts)
{
files: [
"*.{js,mjs,cjs}",
"scripts/**",
"esbuild.config.mjs",
"version-bump.mjs",
"wasmPlugin.mjs",
"nodeModuleShim.mjs",
"jest.config.js",
"tailwind.config.js",
],
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
"import/no-nodejs-modules": "off",
},
},
// TypeScript-specific overrides (the @typescript-eslint plugin is registered
// by obsidianmd's recommended config only for .ts/.tsx files).
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
// checksVoidReturn relaxed for:
// - attributes: async event handlers in JSX (onClick={async () => ...}) are
// the standard React pattern; React already handles them correctly.
// - inheritedMethods: Obsidian's Plugin.onload/onunload are commonly async.
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false, inheritedMethods: false } },
],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/unbound-method": "error",
// TypeScript handles undefined-identifier detection (and does so cross-realm
// correctly); per typescript-eslint's own guidance, disable no-undef on TS.
"no-undef": "off",
},
},
// Non-TS files aren't in tsconfig.json — disable type-aware rules that
// obsidianmd's recommended config enables globally. Most typed obsidianmd
// rules are already gated to **/*.ts(x); only no-plugin-as-component leaks
// out via recommendedPluginRulesConfig, and @typescript-eslint/no-deprecated
// is enabled globally.
{
files: ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.jsx", "**/package.json"],
rules: {
"@typescript-eslint/no-deprecated": "off",
"obsidianmd/no-plugin-as-component": "off",
},
},
// package.json: keep depend/ban-dependencies enabled (from obsidianmd
// recommended) but allow the deps we deliberately keep.
{
files: ["**/package.json"],
rules: {
"depend/ban-dependencies": [
"error",
{
presets: ["native", "microutilities", "preferred"],
allowed: [],
},
],
},
},
// logger.ts is the central logging utility and must call console.* directly.
// scripts/** are CLI tools that print to stdout.
{
files: ["src/logger.ts", "scripts/**"],
rules: {
"obsidianmd/rule-custom-message": "off",
},
},
// Jest assertions like `expect(mock.method).toHaveBeenCalled()` reference
// methods unbound by design. The rule has no clean workaround for jest
// patterns (binding changes the reference identity and breaks the assertion),
// so disable it in tests. Scoped to .ts/.tsx because the @typescript-eslint
// plugin is only registered for those files. Placed last so it overrides the
// TS-only block above.
{
files: ["**/*.test.{ts,tsx}"],
rules: {
"@typescript-eslint/unbound-method": "off",
},
},
];

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

BIN
images/Add-Context.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
images/Agent-Mode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 723 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

BIN
images/Create-Command.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

BIN
images/Note-Image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 855 KiB

BIN
images/Quick-Command.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

After

Width:  |  Height:  |  Size: 940 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 963 KiB

BIN
images/chat mode image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View file

@ -6,9 +6,12 @@ module.exports = {
"^.+\\.(js|jsx|ts|tsx)$": "ts-jest",
},
moduleNameMapper: {
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
"^@/(.*)$": "<rootDir>/src/$1",
"^obsidian$": "<rootDir>/__mocks__/obsidian.js",
// The yaml package's "exports" field defaults to a browser ESM entry under
// jsdom; Jest can't parse ESM without extra config, so point at the CJS
// build it ships under dist/.
"^yaml$": "<rootDir>/node_modules/yaml/dist/index.js",
},
testRegex: ".*\\.test\\.(jsx?|tsx?)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],

View file

@ -1,5 +1,24 @@
import "web-streams-polyfill/dist/polyfill.min.js";
import { TextEncoder, TextDecoder } from "util";
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
window.TextEncoder = TextEncoder;
window.TextDecoder = TextDecoder;
// Polyfill Obsidian's Node.doc / Node.win augmentation so plugin code that
// reads `element.doc` / `element.win` works under jsdom.
if (typeof Node !== "undefined" && !Object.prototype.hasOwnProperty.call(Node.prototype, "doc")) {
Object.defineProperty(Node.prototype, "doc", {
get() {
return this.ownerDocument ?? window.document;
},
configurable: true,
});
}
if (typeof Node !== "undefined" && !Object.prototype.hasOwnProperty.call(Node.prototype, "win")) {
Object.defineProperty(Node.prototype, "win", {
get() {
return this.ownerDocument?.defaultView ?? window;
},
configurable: true,
});
}

7
knip.json Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/main.ts", "scripts/printPromptDebug.js", "scripts/printPromptDebugEntry.ts"],
"project": ["src/**/*.{ts,tsx,js,jsx}", "scripts/**/*.{ts,js,mjs}"],
"ignore": ["src/styles/tailwind.css", "src/integration_tests/**"],
"ignoreDependencies": ["buffer"]
}

View file

@ -1,8 +1,9 @@
{
"id": "copilot",
"name": "Copilot",
"version": "3.0.3",
"minAppVersion": "0.15.0",
"version": "3.3.3",
"minAppVersion": "1.11.4",
"isDesktopOnly": false,
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"author": "Logan Yang",
"authorUrl": "https://twitter.com/logancyang",

37
nodeModuleShim.mjs Normal file
View file

@ -0,0 +1,37 @@
// Plugin to provide a shim for node:module in browser/Electron renderer context
const nodeModuleShim = {
name: "node-module-shim",
setup(build) {
// Intercept node:module imports and provide a shim
build.onResolve({ filter: /^node:module$/ }, (args) => {
return {
path: args.path,
namespace: "node-module-shim",
};
});
build.onLoad({ filter: /.*/, namespace: "node-module-shim" }, () => {
return {
contents: `
// Shim for node:module in Electron/Obsidian environment (CommonJS format)
module.exports = {
createRequire: function(filename) {
// In Electron renderer, we can use the global require
// Note: filename parameter is ignored (may be undefined from @langchain/community v1.0.0)
if (typeof require !== 'undefined') {
return require;
}
// Fallback: return a function that throws a helpful error
return function shimmedRequire(id) {
throw new Error('Dynamic require of "' + id + '" is not supported in this environment');
};
}
};
`,
loader: "js",
};
});
},
};
export default nodeModuleShim;

14185
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,146 +1,125 @@
{
"name": "obsidian-copilot",
"version": "3.0.3",
"version": "3.3.3",
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"main": "main.js",
"scripts": {
"dev": "npm-run-all --parallel dev:*",
"dev": "run-p dev:*",
"dev:tailwind": "npx tailwindcss -i src/styles/tailwind.css -o styles.css --watch",
"dev:esbuild": "node esbuild.config.mjs",
"build": "npm run build:tailwind && npm run build:esbuild || exit 1",
"build:tailwind": "npx tailwindcss -i src/styles/tailwind.css -o styles.css --minify",
"build:esbuild": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint": "eslint .",
"lint:dead": "npx --yes knip@5",
"lint:fix": "eslint . --fix",
"format": "prettier --write 'src/**/*.{js,ts,tsx,md}'",
"format:check": "prettier --check 'src/**/*.{js,ts,tsx,md}'",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"version": "node version-bump.mjs",
"test": "jest --testPathIgnorePatterns=src/integration_tests/",
"test:integration": "jest src/integration_tests/",
"prepare": "husky"
"prepare": "husky",
"prompt:debug": "node scripts/printPromptDebug.js",
"test:vault": "bash scripts/test-vault.sh"
},
"keywords": [],
"author": "Logan Yang",
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"nano-staged": {
"*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write",
"git add"
"prettier --write"
],
"*.{js,jsx,ts,tsx}": [
"eslint --fix"
]
},
"license": "AGPL-3.0",
"devDependencies": {
"@langchain/ollama": "^0.2.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@eslint-react/eslint-plugin": "^1.38.4",
"@google/generative-ai": "^0.24.0",
"@jest/globals": "^29.7.0",
"@langchain/ollama": "^1.2.2",
"@tailwindcss/container-queries": "^0.1.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/crypto-js": "^4.1.1",
"@types/diff": "^7.0.1",
"@types/events": "^3.0.0",
"@types/jest": "^29.5.11",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/lodash.debounce": "^4.0.9",
"@types/luxon": "^3.4.2",
"@types/node": "^16.11.6",
"@types/node-fetch": "^2.6.12",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"@types/react-syntax-highlighter": "^15.5.6",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"builtin-modules": "3.3.0",
"@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0",
"electron": "^27.3.2",
"esbuild": "^0.25.0",
"eslint": "^8.57.0",
"eslint-plugin-json": "^4.0.1",
"eslint-plugin-react": "^7.37.3",
"esbuild-plugin-svg": "^0.1.0",
"eslint": "^9.18.0",
"eslint-plugin-obsidianmd": "^0.3.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-tailwindcss": "^3.18.0",
"globals": "^15.14.0",
"husky": "^9.1.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.5.0",
"lint-staged": "^15.2.9",
"node-fetch": "^2.7.0",
"npm-run-all": "^4.1.5",
"nano-staged": "^0.8.0",
"npm-run-all2": "^7.0.2",
"obsidian": "^1.2.5",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.15",
"tailwindcss-animate": "^1.0.7",
"ts-jest": "^29.1.0",
"tslib": "2.4.0",
"typescript": "^5.7.2",
"web-streams-polyfill": "^3.3.2"
"web-streams-polyfill": "^3.3.2",
"yaml": "^2.6.1"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@google/generative-ai": "^0.21.0",
"@huggingface/inference": "^2.6.4",
"@koa/cors": "^5.0.0",
"@langchain/anthropic": "^0.3.20",
"@langchain/cohere": "^0.3.0",
"@langchain/community": "^0.3.22",
"@langchain/core": "^0.3.45",
"@langchain/deepseek": "^0.0.1",
"@langchain/google-genai": "^0.1.6",
"@langchain/groq": "^0.1.2",
"@langchain/mistralai": "^0.2.0",
"@langchain/openai": "^0.6.6",
"@langchain/xai": "^0.0.2",
"@langchain/anthropic": "^1.3.29",
"@langchain/classic": "^1.0.9",
"@langchain/core": "^1.1.29",
"@langchain/deepseek": "^1.0.0",
"@langchain/google-genai": "^2.1.23",
"@langchain/groq": "^1.0.0",
"@langchain/openai": "^1.0.0",
"@langchain/textsplitters": "^1.0.0",
"@langchain/xai": "^1.0.0",
"@lexical/react": "^0.34.0",
"@orama/orama": "^3.0.0-rc-2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tabler/icons-react": "^2.14.0",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"async-mutex": "^0.5.0",
"axios": "^1.3.4",
"buffer": "^6.0.3",
"chrono-node": "^2.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror-companion-extension": "^0.0.11",
"cohere-ai": "^7.13.0",
"crypto-js": "^4.1.1",
"diff": "^7.0.0",
"esbuild-plugin-svg": "^0.1.0",
"eventsource-parser": "^1.0.0",
"flexsearch": "^0.8.205",
"fuzzysort": "^3.1.0",
"jotai": "^2.10.3",
"koa": "^2.14.2",
"koa-proxies": "^0.12.3",
"langchain": "^0.3.2",
"lodash.debounce": "^4.0.8",
"lexical": "^0.34.0",
"lucide-react": "^0.462.0",
"luxon": "^3.5.0",
"next-i18next": "^13.2.2",
"p-queue": "^8.1.0",
"prop-types": "^15.8.1",
"minisearch": "^7.2.0",
"openai": "^6.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.5",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.2",
"react-syntax-highlighter": "^15.5.0",
"sse": "github:mpetazzoni/sse.js",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"trie-search": "^2.2.0"
"turndown": "^7.2.2",
"uuid": "^11.1.0",
"zod": "^3.25.76"
}
}

62
scripts/printPromptDebug.js Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Bundle the TypeScript entry file into a temporary ESM module and execute it.
*/
async function main() {
const [{ build }, fs, os, path, url] = await Promise.all([
import("esbuild"),
import("node:fs/promises"),
import("node:os"),
import("node:path"),
import("node:url"),
]);
const entryFile = path.resolve(__dirname, "printPromptDebugEntry.ts");
const outfile = path.join(os.tmpdir(), `prompt-debug-${Date.now()}.mjs`);
if (!Array.prototype.contains) {
Object.defineProperty(Array.prototype, "contains", {
value(value) {
return this.includes(value);
},
enumerable: false,
});
}
const obsidianStubPlugin = {
name: "obsidian-stub",
setup(build) {
build.onResolve({ filter: /^obsidian$/ }, () => ({
path: path.resolve(__dirname, "stubs/obsidian.ts"),
}));
},
};
await build({
entryPoints: [entryFile],
outfile,
bundle: true,
platform: "node",
format: "esm",
sourcemap: false,
target: "node18",
tsconfig: path.resolve(__dirname, "../tsconfig.json"),
plugins: [obsidianStubPlugin],
});
try {
// eslint-disable-next-line no-unsanitized/method -- outfile is a controlled path under os.tmpdir(), produced by the esbuild step above.
const module = await import(url.pathToFileURL(outfile).href);
await module.run(process.argv.slice(2));
} finally {
await fs.unlink(outfile).catch(() => {
/* ignore cleanup errors */
});
}
}
main().catch((error) => {
console.error("Failed to generate prompt debug report:", error);
process.exitCode = 1;
});

View file

@ -0,0 +1,134 @@
import type ChainManager from "@/LLMProviders/chainManager";
import MemoryManager from "@/LLMProviders/memoryManager";
import { ModelAdapterFactory } from "@/LLMProviders/chainRunner/utils/modelAdapter";
import { buildAgentPromptDebugReport } from "@/LLMProviders/chainRunner/utils/promptDebugService";
import { ToolRegistry } from "@/tools/ToolRegistry";
import { ChatMessage } from "@/types/message";
import { initializeBuiltinTools } from "@/tools/builtinTools";
import { getSettings } from "@/settings/model";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
import type { App } from "obsidian";
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
interface HeadlessApp {
vault: {
getRoot: () => { name: string };
getAbstractFileByPath: (path: string) => null;
read: (file: unknown) => Promise<string>;
getMarkdownFiles: () => unknown[];
getAllLoadedFiles: () => unknown[];
adapter: {
mkdir: (path: string) => Promise<void>;
};
};
metadataCache: {
getFirstLinkpathDest: () => null;
getFileCache: () => null;
};
workspace: {
getActiveFile: () => null;
getLeaf: () => { openFile: () => Promise<void> };
};
}
/**
* Create a minimal Obsidian app stub suitable for CLI usage.
*
* The autonomous agent only needs vault lookups and metadata cache reads, so this
* provides no-op implementations that satisfy those expectations.
*/
function createHeadlessApp(): HeadlessApp {
return {
vault: {
getRoot: () => ({ name: "root" }),
getAbstractFileByPath: () => null,
read: async () => "",
getMarkdownFiles: () => [],
getAllLoadedFiles: () => [],
adapter: {
mkdir: async () => {
/* no-op */
},
},
},
metadataCache: {
getFirstLinkpathDest: () => null,
getFileCache: () => null,
},
workspace: {
getActiveFile: () => null,
getLeaf: () => ({
openFile: async () => {
/* no-op */
},
}),
},
};
}
/**
* Format a plain user message into the ChatMessage shape used by the agent.
*
* @param message - Raw user text to analyse.
* @returns Minimal chat message.
*/
function buildChatMessage(message: string): ChatMessage {
return {
message,
originalMessage: message,
sender: "user",
timestamp: null,
isVisible: true,
};
}
/**
* Generate the annotated prompt debug report for a given user input.
*
* @param args - CLI arguments (expects the user prompt as the concatenated string).
*/
export async function run(args: string[]): Promise<void> {
const userInput = args.join(" ").trim();
if (!userInput) {
console.error('Usage: npm run prompt:debug -- "your message here"');
process.exitCode = 1;
return;
}
const app = createHeadlessApp();
// eslint-disable-next-line obsidianmd/no-global-this -- node-only debug script, no window available
(global as unknown as { app: unknown }).app = app;
initializeBuiltinTools();
const registry = ToolRegistry.getInstance();
const settings = getSettings();
const enabledToolIds = new Set(settings.autonomousAgentEnabledToolIds || []);
const availableTools = registry.getEnabledTools(enabledToolIds, false);
// Generate simple tool descriptions (native tool calling handles schema via bindTools)
const toolDescriptions = availableTools
.map((tool) => `${tool.name}: ${tool.description}`)
.join("\n");
const memoryManager = MemoryManager.getInstance();
const userMemoryManager = new UserMemoryManager(app as unknown as App);
const chainContext = {
memoryManager,
userMemoryManager,
} as unknown as ChainManager;
const adapter = ModelAdapterFactory.createAdapter({
modelName: "gpt-4",
} as unknown as BaseChatModel);
const report = await buildAgentPromptDebugReport({
chainManager: chainContext,
adapter,
availableTools,
toolDescriptions,
userMessage: buildChatMessage(userInput),
});
console.log(report.annotatedPrompt);
}

93
scripts/stubs/obsidian.ts Normal file
View file

@ -0,0 +1,93 @@
export class App {}
export class Notice {
constructor(public message?: string) {
if (message) {
console.warn(`[Notice] ${message}`);
}
}
}
export class TFile {
path: string;
basename: string;
extension: string;
constructor(path: string) {
this.path = path;
this.basename = path.split("/").pop() || path;
const parts = this.basename.split(".");
this.extension = parts.length > 1 ? parts.pop() || "" : "";
}
}
export class Vault {
getRoot() {
return { name: "root" };
}
getAbstractFileByPath() {
return null;
}
async read() {
return "";
}
getMarkdownFiles() {
return [];
}
getAllLoadedFiles() {
return [];
}
adapter = {
mkdir: async () => {
/* no-op */
},
};
}
export const Platform = {
isDesktop: true,
isMobile: false,
};
export function normalizePath(path: string): string {
return path;
}
export async function requestUrl(): Promise<never> {
throw new Error("requestUrl is not available in the CLI environment.");
}
export function getAllTags(): string[] {
return [];
}
export class MarkdownView {}
export class TAbstractFile {}
export class WorkspaceLeaf {
async openFile(): Promise<void> {
/* no-op */
}
}
export class ItemView {}
export class Modal {
open(): void {
/* no-op */
}
close(): void {
/* no-op */
}
}
export function parseYaml(_: string): unknown {
return {};
}

91
scripts/test-vault.sh Executable file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
OBSIDIAN_BIN="/Applications/Obsidian.app/Contents/MacOS/obsidian"
if [[ -z "${COPILOT_TEST_VAULT_PATH:-}" ]]; then
cat >&2 <<'EOF'
error: COPILOT_TEST_VAULT_PATH is not set.
Set it once at the user level (e.g. in ~/.zshrc or ~/.config/fish/config.fish)
to the absolute path of an Obsidian vault you've opened at least once:
export COPILOT_TEST_VAULT_PATH="$HOME/Obsidian/CopilotTestVault"
Then re-run: npm run test:vault
EOF
exit 1
fi
VAULT_PATH="$COPILOT_TEST_VAULT_PATH"
if [[ ! -d "$VAULT_PATH" ]]; then
echo "error: vault directory not found: $VAULT_PATH" >&2
exit 1
fi
if [[ ! -d "$VAULT_PATH/.obsidian" ]]; then
echo "error: $VAULT_PATH has no .obsidian/ folder." >&2
echo "Open the folder as a vault in Obsidian once, then re-run." >&2
exit 1
fi
WORKTREE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$WORKTREE_ROOT"
echo "==> Installing dependencies"
npm install --prefer-offline --no-audit --no-fund
echo "==> Building plugin"
npm run build
PLUGIN_ID="$(node -p "require('./manifest.json').id")"
if [[ -z "$PLUGIN_ID" ]]; then
echo "error: could not read plugin id from manifest.json" >&2
exit 1
fi
PLUGIN_DIR="$VAULT_PATH/.obsidian/plugins/$PLUGIN_ID"
mkdir -p "$PLUGIN_DIR"
echo "==> Linking artifacts into $PLUGIN_DIR"
for f in main.js styles.css; do
if [[ ! -f "$WORKTREE_ROOT/$f" ]]; then
echo "error: expected build artifact missing: $WORKTREE_ROOT/$f" >&2
exit 1
fi
ln -sfn "$WORKTREE_ROOT/$f" "$PLUGIN_DIR/$f"
done
# Write a branch- and timestamp-tagged manifest.json (real file, not a symlink)
# so Obsidian's Community plugins list visibly reflects which worktree/branch
# is loaded and when this build was deployed.
BRANCH="$(git -C "$WORKTREE_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
BUILD_TS="$(date +%Y%m%d-%H%M%S)"
echo "==> Writing branch-tagged manifest.json (branch: $BRANCH, build: $BUILD_TS)"
rm -f "$PLUGIN_DIR/manifest.json"
SRC="$WORKTREE_ROOT/manifest.json" DEST="$PLUGIN_DIR/manifest.json" BRANCH="$BRANCH" BUILD_TS="$BUILD_TS" node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync(process.env.SRC, "utf8"));
m.name = m.name + " [" + process.env.BRANCH + " @ " + process.env.BUILD_TS + "]";
m.description = "[branch: " + process.env.BRANCH + " | build: " + process.env.BUILD_TS + "] " + m.description;
fs.writeFileSync(process.env.DEST, JSON.stringify(m, null, 2) + "\n");
'
echo "==> Reloading plugin in Obsidian"
if [[ ! -x "$OBSIDIAN_BIN" ]]; then
echo "warning: Obsidian CLI not found at $OBSIDIAN_BIN; skipping reload." >&2
else
if ! "$OBSIDIAN_BIN" plugin:enable id="$PLUGIN_ID" >/dev/null 2>&1 \
|| ! "$OBSIDIAN_BIN" plugin:reload id="$PLUGIN_ID" >/dev/null 2>&1; then
echo "warning: Obsidian doesn't appear to be running. Start it and the symlinked plugin will load on next open." >&2
fi
fi
echo
echo "Done."
echo " worktree: $WORKTREE_ROOT"
echo " branch: $BRANCH"
echo " build: $BUILD_TS"
echo " vault: $VAULT_PATH"
echo " plugin: $PLUGIN_ID"

View file

@ -0,0 +1,834 @@
import { BedrockChatModel } from "./BedrockChatModel";
type ProcessStreamResult = {
deltaChunks: Array<{
text?: string;
message: {
content: unknown;
additional_kwargs?: { delta?: { reasoning?: string } };
};
}>;
usage?: Record<string, unknown>;
stopReason?: string;
hasText: boolean;
debugSummaries: string[];
};
type ContentItem = { type: string; text?: string; thinking?: string };
type ImageContent = {
type: "image";
source: { type: "base64"; media_type: string; data: string };
} | null;
type RequestBody = {
thinking?:
| { type: "enabled"; budget_tokens: number }
| { type: "adaptive"; display?: "summarized" | "omitted" };
temperature?: number;
anthropic_version?: string;
messages: Array<{
role: string;
content: Array<{
type: string;
text?: string;
source?: { type: string; media_type: string; data: string };
}>;
}>;
};
type BedrockInternal = {
decodeChunkBytes: (encoded: string) => string[];
processStreamEvent: (
event: unknown,
runManager: unknown,
currentUsage: unknown,
currentStopReason: unknown
) => Promise<ProcessStreamResult>;
buildContentItemsFromDelta: (event: unknown) => ContentItem[] | null;
extractStreamText: (event: unknown) => string | null;
buildRequestBody: (messages: unknown[], options?: unknown) => RequestBody;
convertImageContent: (imageUrl: string) => ImageContent;
normaliseMessageContent: (
message: unknown
) => string | Array<{ type: string; [key: string]: unknown }>;
};
const asInternal = (m: BedrockChatModel): BedrockInternal => m as unknown as BedrockInternal;
/**
* Builds a minimal Amazon EventStream message containing the provided UTF-8 payload.
* This helper keeps CRC fields at zero because the decoder ignores them.
*/
const buildEventStreamChunk = (payload: string): string => {
const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
const payloadBytes = encoder ? encoder.encode(payload) : Buffer.from(payload, "utf-8");
const headersLength = 0;
const totalLength = 12 + headersLength + payloadBytes.length + 4;
const buffer = new Uint8Array(totalLength);
const view = new DataView(buffer.buffer);
view.setUint32(0, totalLength, false);
view.setUint32(4, headersLength, false);
view.setUint32(8, 0, false); // Prelude CRC (ignored by decoder)
buffer.set(payloadBytes, 12);
view.setUint32(totalLength - 4, 0, false); // Message CRC (ignored by decoder)
return Buffer.from(buffer).toString("base64");
};
const createModel = (
enableThinking = false,
modelId = "anthropic.claude-3-haiku-20240307-v1:0"
): BedrockChatModel =>
new BedrockChatModel({
modelId,
apiKey: "test-key",
endpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke`,
streamEndpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`,
anthropicVersion: "bedrock-2023-05-31",
enableThinking,
fetchImplementation: jest.fn(),
});
const createModelWithFetch = (
fetchMock: jest.Mock,
opts?: { modelId?: string; noStream?: boolean }
): BedrockChatModel =>
new BedrockChatModel({
modelId: opts?.modelId ?? "anthropic.claude-sonnet-4-5-20250929-v1:0",
apiKey: "test-key",
endpoint: "https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke",
...(opts?.noStream
? {}
: {
streamEndpoint:
"https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke-with-response-stream",
}),
anthropicVersion: "bedrock-2023-05-31",
fetchImplementation: fetchMock,
});
describe("BedrockChatModel streaming decode", () => {
it("decodes simple base64 JSON payloads", () => {
const payload = JSON.stringify({
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: { text: "Hello there" },
},
});
const base64 = Buffer.from(payload, "utf-8").toString("base64");
const model = createModel();
const decoded = asInternal(model).decodeChunkBytes(base64);
expect(decoded).toEqual([payload]);
});
it("extracts payloads from Amazon EventStream encoded chunks", () => {
const payload = JSON.stringify({
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: { type: "text_delta", text: "Streaming works!" },
},
});
const base64 = buildEventStreamChunk(payload);
const model = createModel();
const decoded = asInternal(model).decodeChunkBytes(base64);
expect(decoded).toEqual([payload]);
});
it("produces ChatGenerationChunk entries for decoded deltas", async () => {
const payload = JSON.stringify({
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: { type: "text_delta", text: "Chunk text" },
},
});
const base64 = buildEventStreamChunk(payload);
const event = {
type: "chunk",
chunk: { bytes: base64 },
};
const model = createModel();
const processed = await asInternal(model).processStreamEvent(
event,
undefined,
undefined,
undefined
);
expect(processed.hasText).toBe(true);
expect(processed.deltaChunks).toHaveLength(1);
expect(processed.deltaChunks[0]?.text).toBe("Chunk text");
});
describe("thinking content support", () => {
it("buildContentItemsFromDelta recognizes thinking delta type", () => {
const event = {
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: {
type: "thinking",
thinking: "Let me analyze this problem...",
},
},
};
const model = createModel();
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
expect(contentItems).toHaveLength(1);
expect(contentItems![0]).toEqual({
type: "thinking",
thinking: "Let me analyze this problem...",
});
});
it("buildContentItemsFromDelta recognizes text_delta type", () => {
const event = {
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: {
type: "text_delta",
text: "Based on my analysis, the answer is...",
},
},
};
const model = createModel();
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
expect(contentItems).toHaveLength(1);
expect(contentItems![0]).toEqual({
type: "text",
text: "Based on my analysis, the answer is...",
});
});
it("processStreamEvent returns chunks with thinking content array", async () => {
const payload = JSON.stringify({
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: {
type: "thinking",
thinking: "Reasoning through this...",
},
},
});
const base64 = buildEventStreamChunk(payload);
const event = {
type: "chunk",
chunk: { bytes: base64 },
};
const model = createModel();
const processed = await asInternal(model).processStreamEvent(
event,
undefined,
undefined,
undefined
);
expect(processed.hasText).toBe(true);
expect(processed.deltaChunks).toHaveLength(1);
const chunk = processed.deltaChunks[0];
expect(chunk?.text).toBe("Reasoning through this...");
// Check that content is an array with thinking type
expect(Array.isArray(chunk?.message.content)).toBe(true);
const content = chunk?.message.content as unknown[];
expect(content).toHaveLength(1);
expect(content[0]).toEqual({
type: "thinking",
thinking: "Reasoning through this...",
});
// Check for OpenRouter compatibility
expect(chunk?.message.additional_kwargs).toBeDefined();
expect(chunk?.message.additional_kwargs?.delta).toEqual({
reasoning: "Reasoning through this...",
});
});
it("processStreamEvent returns chunks with text content array", async () => {
const payload = JSON.stringify({
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: {
type: "text_delta",
text: "Here is my final answer.",
},
},
});
const base64 = buildEventStreamChunk(payload);
const event = {
type: "chunk",
chunk: { bytes: base64 },
};
const model = createModel();
const processed = await asInternal(model).processStreamEvent(
event,
undefined,
undefined,
undefined
);
expect(processed.hasText).toBe(true);
expect(processed.deltaChunks).toHaveLength(1);
const chunk = processed.deltaChunks[0];
expect(chunk?.text).toBe("Here is my final answer.");
// Check that content is an array with text type
expect(Array.isArray(chunk?.message.content)).toBe(true);
const content = chunk?.message.content as unknown[];
expect(content).toHaveLength(1);
expect(content[0]).toEqual({
type: "text",
text: "Here is my final answer.",
});
// No additional_kwargs.delta for regular text
expect(chunk?.message.additional_kwargs?.delta).toBeUndefined();
});
it("handles mixed thinking and text deltas correctly", async () => {
const model = createModel();
// First chunk: thinking
const thinkingPayload = JSON.stringify({
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: {
type: "thinking",
thinking: "First, I'll consider...",
},
},
});
const thinkingBase64 = buildEventStreamChunk(thinkingPayload);
const thinkingEvent = {
type: "chunk",
chunk: { bytes: thinkingBase64 },
};
const thinkingResult = await asInternal(model).processStreamEvent(
thinkingEvent,
undefined,
undefined,
undefined
);
expect(thinkingResult.deltaChunks).toHaveLength(1);
const thinkingChunk = thinkingResult.deltaChunks[0];
expect((thinkingChunk?.message.content as Array<{ type: string }>)[0]?.type).toBe("thinking");
// Second chunk: text
const textPayload = JSON.stringify({
type: "content_block_delta",
content_block_delta: {
index: 0,
delta: {
type: "text_delta",
text: "Therefore, the answer is X.",
},
},
});
const textBase64 = buildEventStreamChunk(textPayload);
const textEvent = {
type: "chunk",
chunk: { bytes: textBase64 },
};
const textResult = await asInternal(model).processStreamEvent(
textEvent,
undefined,
undefined,
undefined
);
expect(textResult.deltaChunks).toHaveLength(1);
const textChunk = textResult.deltaChunks[0];
expect((textChunk?.message.content as Array<{ type: string }>)[0]?.type).toBe("text");
});
it("extractStreamText can fallback to extract thinking content", () => {
const event = {
type: "content_block_delta",
content_block_delta: {
delta: {
type: "thinking",
thinking: "Fallback thinking extraction",
},
},
};
const model = createModel();
const extracted = asInternal(model).extractStreamText(event);
expect(extracted).toBe("Fallback thinking extraction");
});
it("handles empty thinking content gracefully", () => {
const event = {
type: "content_block_delta",
content_block_delta: {
delta: {
type: "thinking",
thinking: "",
},
},
};
const model = createModel();
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
expect(contentItems).toHaveLength(1);
expect(contentItems![0]).toEqual({
type: "thinking",
thinking: "",
});
});
});
describe("thinking mode enablement", () => {
it("includes thinking parameter when enableThinking is true", () => {
const model = createModel(true);
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({
type: "enabled",
budget_tokens: 2048,
});
expect(requestBody.temperature).toBe(1);
expect(requestBody.anthropic_version).toBe("bedrock-2023-05-31");
});
it("does not include thinking parameter when enableThinking is false", () => {
const model = createModel(false);
const requestBody = asInternal(model).buildRequestBody(
[{ role: "user", content: "test", getType: () => "human" }],
{ temperature: 0.7 }
);
expect(requestBody.thinking).toBeUndefined();
expect(requestBody.temperature).toBe(0.7);
// anthropic_version should always be present when provided (required for all Bedrock requests)
expect(requestBody.anthropic_version).toBe("bedrock-2023-05-31");
});
it("respects user temperature when thinking is disabled", () => {
const model = createModel(false);
const requestBody = asInternal(model).buildRequestBody(
[{ role: "user", content: "test", getType: () => "human" }],
{ temperature: 0.5 }
);
expect(requestBody.temperature).toBe(0.5);
expect(requestBody.thinking).toBeUndefined();
});
it("forces temperature to 1 when thinking is enabled", () => {
const model = createModel(true);
const requestBody = asInternal(model).buildRequestBody(
[{ role: "user", content: "test", getType: () => "human" }],
{ temperature: 0.5 } // User tries to set 0.5, should be overridden to 1
);
expect(requestBody.temperature).toBe(1);
expect(requestBody.thinking).toBeDefined();
});
it("uses adaptive thinking with summarized display for claude-opus-4-7", () => {
const model = createModel(true, "anthropic.claude-opus-4-7-20260115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(requestBody.temperature).toBe(1);
});
it("uses adaptive thinking for opus-4-7 cross-region inference profiles", () => {
const model = createModel(true, "global.anthropic.claude-opus-4-7-20260115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "adaptive", display: "summarized" });
});
it("keeps legacy thinking for opus-4-6 and earlier", () => {
const model = createModel(true, "anthropic.claude-opus-4-6-20250115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "enabled", budget_tokens: 2048 });
});
it("keeps legacy thinking for sonnet-4 and 3-7-sonnet", () => {
const sonnet45 = createModel(true, "anthropic.claude-sonnet-4-5-20250929-v1:0");
expect(
asInternal(sonnet45).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
const sonnet37 = createModel(true, "anthropic.claude-3-7-sonnet-20250219-v1:0");
expect(
asInternal(sonnet37).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
});
it("keeps legacy thinking for dated Opus 4.0 snapshot IDs", () => {
// anthropic.claude-opus-4-20250514-v1:0 is the dated snapshot of Opus 4.0, not 4.20250514.
const opus40 = createModel(true, "anthropic.claude-opus-4-20250514-v1:0");
expect(
asInternal(opus40).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
// anthropic.claude-opus-4-1-20250805-v1:0 is dated 4.1, not adaptive.
const opus41 = createModel(true, "anthropic.claude-opus-4-1-20250805-v1:0");
expect(
asInternal(opus41).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
});
});
describe("vision support", () => {
describe("convertImageContent", () => {
it("converts valid data URL to Claude image format", () => {
const model = createModel();
const dataUrl = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
const result = asInternal(model).convertImageContent(dataUrl);
expect(result).toEqual({
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "/9j/4AAQSkZJRg==",
},
});
});
it("handles PNG images", () => {
const model = createModel();
const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
const result = asInternal(model).convertImageContent(dataUrl);
expect(result).toEqual({
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "iVBORw0KGgoAAAANSUhEUg==",
},
});
});
it("returns null for invalid data URL format", () => {
const model = createModel();
const invalidUrl = "not-a-data-url";
const result = asInternal(model).convertImageContent(invalidUrl);
expect(result).toBeNull();
});
it("returns null for non-image media type", () => {
const model = createModel();
const dataUrl = "data:text/plain;base64,SGVsbG8gV29ybGQ=";
const result = asInternal(model).convertImageContent(dataUrl);
expect(result).toBeNull();
});
});
describe("normaliseMessageContent", () => {
it("preserves array content with images", () => {
const model = createModel();
const message = {
content: [
{ type: "text", text: "What's in this image?" },
{
type: "image_url",
image_url: { url: "data:image/jpeg;base64,/9j/4AAQSkZJRg==" },
},
],
getType: () => "human",
};
const result = asInternal(model).normaliseMessageContent(message);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ type: "text", text: "What's in this image?" });
expect(result[1]).toEqual({
type: "image_url",
image_url: { url: "data:image/jpeg;base64,/9j/4AAQSkZJRg==" },
});
});
it("flattens array content without images to string", () => {
const model = createModel();
const message = {
content: [
{ type: "text", text: "Hello " },
{ type: "text", text: "world!" },
],
getType: () => "human",
};
const result = asInternal(model).normaliseMessageContent(message);
expect(typeof result).toBe("string");
expect(result).toBe("Hello world!");
});
it("returns string content unchanged", () => {
const model = createModel();
const message = {
content: "Simple text message",
getType: () => "human",
};
const result = asInternal(model).normaliseMessageContent(message);
expect(result).toBe("Simple text message");
});
});
describe("buildRequestBody with images", () => {
it("includes images in request body for multimodal messages", () => {
const model = createModel();
const messages = [
{
content: [
{ type: "text", text: "What's in this image?" },
{
type: "image_url",
image_url: { url: "data:image/jpeg;base64,/9j/4AAQSkZJRg==" },
},
],
getType: () => "human",
},
];
const requestBody = asInternal(model).buildRequestBody(messages);
expect(requestBody.messages).toHaveLength(1);
expect(requestBody.messages[0].content).toHaveLength(2);
// Check text block
expect(requestBody.messages[0].content[0]).toEqual({
type: "text",
text: "What's in this image?",
});
// Check image block (converted to Claude format)
expect(requestBody.messages[0].content[1]).toEqual({
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "/9j/4AAQSkZJRg==",
},
});
});
it("handles multiple images in a single message", () => {
const model = createModel();
const messages = [
{
content: [
{ type: "text", text: "Compare these images:" },
{
type: "image_url",
image_url: { url: "data:image/jpeg;base64,IMAGE1DATA" },
},
{
type: "image_url",
image_url: { url: "data:image/png;base64,IMAGE2DATA" },
},
],
getType: () => "human",
},
];
const requestBody = asInternal(model).buildRequestBody(messages);
expect(requestBody.messages[0].content).toHaveLength(3);
expect(requestBody.messages[0].content[0].type).toBe("text");
expect(requestBody.messages[0].content[1].type).toBe("image");
expect(requestBody.messages[0].content[1].source!.media_type).toBe("image/jpeg");
expect(requestBody.messages[0].content[2].type).toBe("image");
expect(requestBody.messages[0].content[2].source!.media_type).toBe("image/png");
});
it("handles text-only messages correctly", () => {
const model = createModel();
const messages = [
{
content: "Just text, no images",
getType: () => "human",
},
];
const requestBody = asInternal(model).buildRequestBody(messages);
expect(requestBody.messages).toHaveLength(1);
expect(requestBody.messages[0].content).toHaveLength(1);
expect(requestBody.messages[0].content[0]).toEqual({
type: "text",
text: "Just text, no images",
});
});
it("skips invalid images and keeps valid content", () => {
const model = createModel();
const messages = [
{
content: [
{ type: "text", text: "Valid text" },
{
type: "image_url",
image_url: { url: "invalid-url" }, // Invalid - should be skipped
},
{
type: "image_url",
image_url: { url: "data:image/jpeg;base64,VALIDDATA" }, // Valid
},
],
getType: () => "human",
},
];
const requestBody = asInternal(model).buildRequestBody(messages);
// Should have text + 1 valid image (invalid one skipped)
expect(requestBody.messages[0].content).toHaveLength(2);
expect(requestBody.messages[0].content[0].type).toBe("text");
expect(requestBody.messages[0].content[1].type).toBe("image");
});
});
});
});
describe("BedrockChatModel inference-profile error rewriting", () => {
const awsInferenceProfileError = JSON.stringify({
message:
"Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const makeErrorResponse = (status: number, body: string): Response =>
({
ok: false,
status,
text: () => Promise.resolve(body),
}) as unknown as Response;
it("rewrites 400 inference-profile error in non-streaming path to actionable message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/cross-region inference profile ID/
);
});
it("rewrites 400 inference-profile error in streaming path to actionable message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock);
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
const gen = model._streamResponseChunks(messages as never, {});
await expect(gen.next()).rejects.toThrow(/cross-region inference profile ID/);
});
it("includes the bare model ID in the rewritten message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/anthropic\.claude-sonnet-4-5/
);
});
it("does not rewrite a 400 error that is unrelated to inference profiles", async () => {
const genericBody = JSON.stringify({ message: "ValidationException: bad request" });
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, genericBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/Amazon Bedrock request failed with status 400/
);
});
it("does not rewrite non-400 errors", async () => {
const body = JSON.stringify({ message: "Internal Server Error" });
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(500, body));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/Amazon Bedrock request failed with status 500/
);
});
it("rewrites the error even when AWS uses a curly apostrophe in 'isnt supported'", async () => {
const curlyApostropheBody = JSON.stringify({
message:
"Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isnt supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, curlyApostropheBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/cross-region inference profile ID/
);
});
it("uses the provider segment from the bare model ID in the prefix guidance", async () => {
const nonAnthropicBody = JSON.stringify({
message:
"Invocation of model ID meta.llama4-maverick-17b with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, nonAnthropicBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(/global\.meta\.<id>/);
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,87 @@
import { ChatOpenAI } from "@langchain/openai";
/**
* ChatLMStudio extends ChatOpenAI with the Responses API (/v1/responses)
* for LM Studio local inference.
*
* Patches LangChain/OpenAI SDK compatibility issues with LM Studio:
* - Ensures text.format is always set (LM Studio requires it)
* - Removes strict:null from tool definitions (LM Studio rejects it)
*/
export interface ChatLMStudioInput {
modelName?: string;
apiKey?: string;
configuration?: Record<string, unknown>;
temperature?: number;
maxTokens?: number;
topP?: number;
frequencyPenalty?: number;
streaming?: boolean;
streamUsage?: boolean;
[key: string]: unknown;
}
/**
* Create a fetch wrapper that sanitizes request bodies for LM Studio
* compatibility. This intercepts at the HTTP level, which is the last
* stop before the request is sent, guaranteeing all null values in
* tools are stripped regardless of which LangChain code path produced them.
*/
function createLMStudioFetch(baseFetch?: typeof window.fetch): typeof window.fetch {
const underlyingFetch = baseFetch || window.fetch;
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
if (init?.body && typeof init.body === "string") {
try {
const body = JSON.parse(init.body) as { tools?: unknown };
let modified = false;
// Strip null/undefined values from tool definitions
if (Array.isArray(body.tools)) {
body.tools = body.tools.map((tool: Record<string, unknown>) => {
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(tool)) {
if (value !== null && value !== undefined) {
cleaned[key] = value;
}
}
return cleaned;
});
modified = true;
}
if (modified) {
init = { ...init, body: JSON.stringify(body) };
}
} catch {
// Not JSON, pass through unchanged
}
}
return underlyingFetch(input, init);
};
}
export class ChatLMStudio extends ChatOpenAI {
constructor(fields: ChatLMStudioInput) {
const configuration = fields.configuration as { fetch?: typeof window.fetch } | undefined;
const originalFetch = configuration?.fetch;
super({
...fields,
useResponsesApi: true,
configuration: {
...fields.configuration,
// Wrap fetch to sanitize request bodies for LM Studio compatibility
fetch: createLMStudioFetch(originalFetch),
},
// modelKwargs is spread LAST in ChatOpenAIResponses.invocationParams(),
// overriding the computed `text` field. Without this, LangChain emits
// `text: { format: undefined }` (serializes to `text: {}`) which LM Studio
// rejects with "Required: text.format".
modelKwargs: {
...(fields.modelKwargs as Record<string, unknown> | undefined),
text: { format: { type: "text" } },
},
});
}
}

View file

@ -0,0 +1,560 @@
import { BaseChatModelParams } from "@langchain/core/language_models/chat_models";
import { AIMessageChunk, BaseMessage } from "@langchain/core/messages";
import type { UsageMetadata } from "@langchain/core/messages";
import { ChatGenerationChunk } from "@langchain/core/outputs";
import { ChatOpenAI } from "@langchain/openai";
import OpenAI from "openai";
import { logInfo } from "@/logger";
type OpenRouterChatChunk = OpenAI.ChatCompletionChunk;
type OpenRouterUsage = NonNullable<OpenRouterChatChunk["usage"]>;
type OpenRouterMessageParam = OpenAI.ChatCompletionMessageParam;
/**
* ChatOpenRouter extends ChatOpenAI to support OpenRouter-specific features,
* particularly reasoning/thinking tokens.
*
* OpenRouter exposes thinking tokens via the `reasoning` request parameter
* and responds with `reasoning_details` in both streaming and non-streaming modes.
*
* @see https://openrouter.ai/docs/use-cases/reasoning-tokens
*/
export interface ChatOpenRouterInput extends BaseChatModelParams {
/**
* Enable reasoning/thinking tokens from OpenRouter
* When true, requests will include reasoning parameters
*/
enableReasoning?: boolean;
/**
* Reasoning effort level: "minimal", "low", "medium", "high", or "xhigh"
* Controls the amount of reasoning the model uses
* Note: "minimal" will be treated as "low" for OpenRouter
* Note: "xhigh" is only supported by GPT-5.4 models
*/
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
/**
* Enable prompt caching (cache_control) for OpenRouter requests.
* Defaults to true. Set to false for Zero Data Retention (ZDR) endpoints
* that do not support prompt caching.
*/
enablePromptCaching?: boolean;
// All other ChatOpenAI parameters
modelName?: string;
apiKey?: string;
configuration?: {
baseURL?: string;
defaultHeaders?: Record<string, string>;
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
[key: string]: unknown;
};
temperature?: number;
maxTokens?: number;
topP?: number;
frequencyPenalty?: number;
streaming?: boolean;
maxRetries?: number;
maxConcurrency?: number;
[key: string]: unknown;
}
export class ChatOpenRouter extends ChatOpenAI {
private enableReasoning: boolean;
private reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
private enablePromptCaching: boolean;
private openaiClient: OpenAI;
/** True when the configured baseURL belongs to the OpenRouter gateway. */
private isOpenRouter: boolean;
constructor(fields: ChatOpenRouterInput) {
const {
enableReasoning = false,
reasoningEffort,
enablePromptCaching = true,
...rest
} = fields;
// Pass all other parameters to ChatOpenAI
super(rest);
this.enableReasoning = enableReasoning;
this.reasoningEffort = reasoningEffort;
this.enablePromptCaching = enablePromptCaching;
const baseURL = fields.configuration?.baseURL || "https://openrouter.ai/api/v1";
this.isOpenRouter = baseURL.includes("openrouter.ai");
// Create our own OpenAI client for raw access
this.openaiClient = new OpenAI({
apiKey: fields.apiKey,
baseURL,
defaultHeaders: fields.configuration?.defaultHeaders,
fetch: fields.configuration?.fetch,
dangerouslyAllowBrowser: true,
});
}
/**
* Override the invocation parameters to include prompt caching and reasoning when enabled.
*
* Prompt caching: The `cache_control` field opts Anthropic models (via OpenRouter) into
* automatic cache breakpoint detection, reducing token costs on repeated context. This
* field is only sent when connected to the OpenRouter gateway; other backends (LM Studio,
* Copilot Plus) use the same class but must not receive OpenRouter-specific fields.
*
* @see https://openrouter.ai/docs/features/prompt-caching
*/
override invocationParams(options?: this["ParsedCallOptions"]): Record<string, unknown> {
const baseParams = super.invocationParams(options);
// Only inject cache_control for OpenRouter endpoints. LM Studio, Copilot Plus, and
// other OpenAI-compatible backends share this class but reject unknown top-level fields.
// Skip caching when enablePromptCaching is false (e.g. for ZDR endpoints that don't
// support Anthropic's automatic caching).
const withCaching =
this.isOpenRouter && this.enablePromptCaching
? { ...baseParams, cache_control: { type: "ephemeral" } }
: baseParams;
// Add reasoning parameter if enabled
if (this.enableReasoning) {
// Per OpenRouter docs:
// - For Anthropic models: MUST use reasoning.max_tokens or reasoning.effort
// - For other models: Can use reasoning.enabled
// - max_tokens must be strictly higher than reasoning budget
// Prefer effort if provided, otherwise fall back to max_tokens
if (this.reasoningEffort) {
// Map "minimal" to "low" since OpenRouter doesn't support "minimal"
const effort = this.reasoningEffort === "minimal" ? "low" : this.reasoningEffort;
logInfo(`OpenRouter reasoning enabled with effort: ${effort}`);
return {
...withCaching,
reasoning: {
effort,
},
};
} else {
logInfo(`OpenRouter reasoning enabled with max_tokens: 1024`);
return {
...withCaching,
reasoning: {
max_tokens: 1024,
},
};
}
}
return withCaching;
}
/**
* Override to use raw OpenAI SDK to access reasoning_details
* LangChain filters out reasoning_details, so we bypass it completely
*/
override async *_streamResponseChunks(
messages: BaseMessage[],
options: this["ParsedCallOptions"],
_runManager?: { handleLLMNewToken: (token: string) => Promise<void> }
): AsyncGenerator<ChatGenerationChunk> {
const params = this.invocationParams(options);
const openaiMessages = this.toOpenRouterMessages(messages);
const stream = (await this.openaiClient.chat.completions.create({
...params,
messages: openaiMessages,
stream: true,
stream_options: {
...(params.stream_options ?? {}),
include_usage: true,
},
} as Parameters<
typeof this.openaiClient.chat.completions.create
>[0])) as unknown as AsyncIterable<OpenRouterChatChunk>;
let usageSummary: OpenRouterUsage | undefined;
for await (const rawChunk of stream) {
if (rawChunk.usage) {
usageSummary = rawChunk.usage;
}
const choice = rawChunk.choices?.[0];
const delta = choice?.delta;
if (!choice || !delta) {
continue;
}
const reasoningText = this.normalizeReasoningChunk(
(delta as Record<string, unknown>)?.reasoning
);
const reasoningDetails = this.extractReasoningDetails(choice);
const content = this.extractDeltaContent(delta.content);
const messageChunk = this.buildMessageChunk({
rawChunk,
delta: delta as unknown as Record<string, unknown>,
content,
finishReason: choice.finish_reason,
reasoningDetails,
reasoningText,
});
const generationChunk = new ChatGenerationChunk({
message: messageChunk,
text: typeof messageChunk.content === "string" ? messageChunk.content : "",
generationInfo: {
finish_reason: choice.finish_reason,
// Reason: system_fingerprint is marked deprecated by some scorecards but is still
// returned by OpenAI-style streaming APIs and is useful for telemetry.
system_fingerprint: rawChunk["system_fingerprint"],
model: rawChunk.model,
},
});
yield generationChunk;
if (generationChunk.text) {
await _runManager?.handleLLMNewToken(generationChunk.text);
}
}
if (usageSummary) {
yield this.buildUsageGenerationChunk(usageSummary);
}
if (options.signal?.aborted) {
throw new Error("AbortError");
}
}
/**
* Convert LangChain messages to OpenRouter-ready messages.
*
* @param messages LangChain messages passed into the model
* @returns Messages formatted for the OpenRouter API
*/
private toOpenRouterMessages(messages: BaseMessage[]): OpenRouterMessageParam[] {
return messages.map((msg) => {
const msgRecord = msg as unknown as Record<string, unknown>;
const role =
typeof msg._getType === "function"
? msg._getType()
: ((msgRecord.role as string) ?? "user");
const mappedRole =
role === "human"
? "user"
: role === "ai"
? "assistant"
: (role as OpenAI.ChatCompletionRole);
if (msgRecord.tool_call_id) {
return {
role: "tool",
content: msg.content,
tool_call_id: msgRecord.tool_call_id as string,
} as OpenRouterMessageParam;
}
if (msg.additional_kwargs?.function_call) {
return {
role: mappedRole,
content: msg.content,
function_call: msg.additional_kwargs.function_call,
} as OpenRouterMessageParam;
}
// Handle modern tool_calls format (used by autonomous agent)
if (msg.additional_kwargs?.tool_calls) {
return {
role: mappedRole,
content: msg.content,
tool_calls: msg.additional_kwargs.tool_calls,
} as OpenRouterMessageParam;
}
return {
role: mappedRole,
content: msg.content,
} as OpenRouterMessageParam;
});
}
/**
* Build an `AIMessageChunk` enriched with reasoning metadata.
*
* @param config Chunk configuration values extracted from the stream
* @returns AI message chunk ready for downstream streaming utilities
*/
private buildMessageChunk(config: {
rawChunk: OpenRouterChatChunk;
delta: Record<string, unknown>;
content: string;
finishReason: string | null | undefined;
reasoningText?: string;
reasoningDetails?: unknown[];
}): AIMessageChunk {
const { rawChunk, delta, content, finishReason, reasoningText, reasoningDetails } = config;
const toolCallChunks = this.extractToolCallChunks(delta.tool_calls);
const additionalKwargs: Record<string, unknown> = {};
if (delta.function_call) {
additionalKwargs.function_call = delta.function_call;
}
if (Array.isArray(delta.tool_calls)) {
additionalKwargs.tool_calls = delta.tool_calls;
}
const deltaPayload: Record<string, unknown> = {};
if (reasoningText) {
deltaPayload.reasoning = reasoningText;
}
if (reasoningDetails && reasoningDetails.length > 0) {
deltaPayload.reasoning_details = reasoningDetails;
}
if (Object.keys(deltaPayload).length > 0) {
additionalKwargs.delta = {
...(additionalKwargs.delta as Record<string, unknown>),
...deltaPayload,
};
}
if (reasoningDetails && reasoningDetails.length > 0) {
additionalKwargs.reasoning_details = reasoningDetails;
}
const responseMetadata = this.buildResponseMetadata(rawChunk, finishReason);
return new AIMessageChunk({
content,
additional_kwargs: additionalKwargs,
tool_call_chunks: toolCallChunks,
response_metadata: responseMetadata,
id: rawChunk.id,
});
}
/**
* Normalize streamed reasoning payloads into plain text for the UI.
*
* @param reasoning Arbitrary reasoning payload returned by OpenRouter
* @returns Normalized reasoning text or undefined
*/
private normalizeReasoningChunk(reasoning: unknown): string | undefined {
if (!reasoning) {
return undefined;
}
if (typeof reasoning === "string") {
return reasoning;
}
if (Array.isArray(reasoning)) {
return reasoning
.map((item) => this.normalizeReasoningChunk(item))
.filter((item): item is string => Boolean(item))
.join("");
}
if (typeof reasoning === "object") {
const record = reasoning as Record<string, unknown>;
const candidates = [
record.output_text,
record.text,
record.reasoning,
record.thinking,
record.content,
];
const normalized = candidates.find((value) => typeof value === "string");
if (typeof normalized === "string") {
return normalized;
}
}
return undefined;
}
/**
* Extract reasoning details arrays from the streamed choice payload.
*
* @param choice Chunk choice object from the OpenRouter stream
* @returns Array of reasoning detail entries, if present
*/
private extractReasoningDetails(
choice: OpenAI.ChatCompletionChunk.Choice
): unknown[] | undefined {
const choiceRecord = choice as unknown as Record<string, Record<string, unknown>>;
const candidate =
choiceRecord?.delta?.reasoning_details ??
choiceRecord?.message?.reasoning_details ??
(choice as unknown as Record<string, unknown>)?.reasoning_details;
if (!Array.isArray(candidate)) {
return undefined;
}
return (candidate as unknown[]).filter((detail) => detail !== undefined && detail !== null);
}
/**
* Flatten OpenRouter delta content into a single text string.
*
* @param content Delta content payload
* @returns Text representation for downstream streaming
*/
private extractDeltaContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
if (
part &&
typeof part === "object" &&
typeof (part as { text?: unknown }).text === "string"
) {
return (part as { text: string }).text;
}
return "";
})
.join("");
}
return "";
}
/**
* Map raw OpenRouter tool call deltas into LangChain tool call chunks.
*
* @param toolCalls Tool call deltas returned by OpenRouter
* @returns Tool call chunk array compatible with LangChain
*/
private extractToolCallChunks(
toolCalls: unknown
):
| Array<{ name?: string; args?: string; id?: string; index?: number; type: "tool_call_chunk" }>
| undefined {
if (!Array.isArray(toolCalls)) {
return undefined;
}
return toolCalls.map((rawCall) => {
const call = rawCall as
| { function?: { name?: string; arguments?: string }; id?: string; index?: number }
| null
| undefined;
return {
name: call?.function?.name,
args: call?.function?.arguments,
id: call?.id,
index: call?.index,
type: "tool_call_chunk" as const,
};
});
}
/**
* Build response metadata payload with finish reason and usage info.
*
* @param rawChunk Raw streaming chunk from OpenRouter
* @param finishReason Stop reason reported by the model
* @returns Metadata object attached to each AI message chunk
*/
private buildResponseMetadata(
rawChunk: OpenRouterChatChunk,
finishReason: string | null | undefined
): Record<string, unknown> {
const metadata: Record<string, unknown> = {
model_provider: "openrouter",
};
if (finishReason) {
metadata.finish_reason = finishReason;
}
if (rawChunk.model) {
metadata.model = rawChunk.model;
}
// Reason: system_fingerprint is marked deprecated by some scorecards but is still
// returned by OpenAI-style streaming APIs and is useful for telemetry. Use bracket
// access to bypass JSDoc deprecation warnings.
const fingerprint = rawChunk["system_fingerprint"];
if (fingerprint) {
metadata.system_fingerprint = fingerprint;
}
if (rawChunk.usage) {
metadata.usage = { ...rawChunk.usage };
metadata.tokenUsage = {
promptTokens: rawChunk.usage.prompt_tokens,
completionTokens: rawChunk.usage.completion_tokens,
totalTokens: rawChunk.usage.total_tokens,
};
}
return metadata;
}
/**
* Create a terminal usage chunk so downstream consumers can capture token usage.
*
* @param usage Usage payload returned by the streaming API
* @returns Chat generation chunk containing usage metadata
*/
private buildUsageGenerationChunk(usage: OpenRouterUsage): ChatGenerationChunk {
const inputTokenDetails: Record<string, number> = {};
const outputTokenDetails: Record<string, number> = {};
const promptDetails = usage.prompt_tokens_details ?? {};
if (typeof promptDetails.audio_tokens === "number") {
inputTokenDetails.audio = promptDetails.audio_tokens;
}
if (typeof promptDetails.cached_tokens === "number") {
inputTokenDetails.cache_read = promptDetails.cached_tokens;
}
const completionDetails = usage.completion_tokens_details ?? {};
if (typeof completionDetails.audio_tokens === "number") {
outputTokenDetails.audio = completionDetails.audio_tokens;
}
if (typeof completionDetails.reasoning_tokens === "number") {
outputTokenDetails.reasoning = completionDetails.reasoning_tokens;
}
const usageMetadata: UsageMetadata = {
input_tokens: usage.prompt_tokens ?? 0,
output_tokens: usage.completion_tokens ?? 0,
total_tokens: usage.total_tokens ?? 0,
};
if (Object.keys(inputTokenDetails).length > 0) {
usageMetadata.input_token_details = inputTokenDetails;
}
if (Object.keys(outputTokenDetails).length > 0) {
usageMetadata.output_token_details = outputTokenDetails;
}
const messageChunk = new AIMessageChunk({
content: "",
response_metadata: { usage: { ...usage } },
usage_metadata: usageMetadata,
});
return new ChatGenerationChunk({
message: messageChunk,
text: "",
});
}
}

View file

@ -1,15 +1,186 @@
import { JinaEmbeddings, JinaEmbeddingsParams } from "@langchain/community/embeddings/jina";
/*
* Adapted from @langchain/community JinaEmbeddings.
* Copyright (c) LangChain, Inc. Licensed under the MIT License.
* Source: https://github.com/langchain-ai/langchainjs-community/blob/886df5749a926f59e6fdf38a3465c62ec9e7ce32/libs/community/src/embeddings/jina.ts
*/
export class CustomJinaEmbeddings extends JinaEmbeddings {
import { Embeddings, type EmbeddingsParams } from "@langchain/core/embeddings";
import { chunkArray } from "@langchain/core/utils/chunk_array";
import { getEnvironmentVariable } from "@langchain/core/utils/env";
export interface JinaEmbeddingsParams extends EmbeddingsParams {
/** Model name to use. */
model: string;
/** Compatibility alias used by this plugin's embedding manager. */
modelName?: string;
/** Jina-compatible embeddings endpoint. */
baseUrl?: string;
/** Timeout to use when making requests to Jina. */
timeout?: number;
/** The maximum number of documents to embed in a single request. */
batchSize?: number;
/** Whether to strip new lines from the input text. */
stripNewLines?: boolean;
/** The dimensions of the embedding. */
dimensions?: number;
/** Whether to L2-normalize the embedding vectors. */
normalized?: boolean;
}
type JinaMultiModelInput =
| {
text: string;
image?: never;
}
| {
image: string;
text?: never;
};
export type JinaEmbeddingsInput = string | JinaMultiModelInput;
interface EmbeddingCreateParams {
model: JinaEmbeddingsParams["model"];
input: JinaEmbeddingsInput[];
dimensions: number;
task: "retrieval.query" | "retrieval.passage";
normalized?: boolean;
}
interface EmbeddingResponse {
model: string;
object: string;
usage: {
total_tokens: number;
prompt_tokens: number;
};
data: {
object: string;
index: number;
embedding: number[];
}[];
}
interface EmbeddingErrorResponse {
detail: string;
}
export class CustomJinaEmbeddings extends Embeddings implements JinaEmbeddingsParams {
model: JinaEmbeddingsParams["model"] = "jina-clip-v2";
batchSize = 24;
baseUrl = "https://api.jina.ai/v1/embeddings";
stripNewLines = true;
dimensions = 1024;
apiKey: string;
normalized = true;
/**
* Creates a Jina embeddings client using local configuration or Jina environment variables.
*/
constructor(
fields?: Partial<JinaEmbeddingsParams> & {
apiKey?: string;
baseUrl?: string;
}
) {
super(fields);
if (fields?.baseUrl) {
this.baseUrl = fields.baseUrl;
const fieldsWithDefaults = { maxConcurrency: 2, ...fields };
super(fieldsWithDefaults);
const apiKey =
fieldsWithDefaults?.apiKey ||
getEnvironmentVariable("JINA_API_KEY") ||
getEnvironmentVariable("JINA_AUTH_TOKEN");
if (!apiKey) throw new Error("Jina API key not found");
this.apiKey = apiKey;
this.model = fieldsWithDefaults?.model ?? fieldsWithDefaults?.modelName ?? this.model;
this.baseUrl = fieldsWithDefaults?.baseUrl ?? this.baseUrl;
this.dimensions = fieldsWithDefaults?.dimensions ?? this.dimensions;
this.batchSize = fieldsWithDefaults?.batchSize ?? this.batchSize;
this.stripNewLines = fieldsWithDefaults?.stripNewLines ?? this.stripNewLines;
this.normalized = fieldsWithDefaults?.normalized ?? this.normalized;
}
/**
* Embeds passage documents with Jina retrieval-passage task parameters.
*/
async embedDocuments(input: JinaEmbeddingsInput[]): Promise<number[][]> {
const batches = chunkArray(this.doStripNewLines(input), this.batchSize);
const batchRequests = batches.map((batch) => {
const params = this.getParams(batch);
return this.embeddingWithRetry(params);
});
const batchResponses = await Promise.all(batchRequests);
const embeddings: number[][] = [];
for (let i = 0; i < batchResponses.length; i += 1) {
const batch = batches[i];
const batchResponse = batchResponses[i] || [];
for (let j = 0; j < batch.length; j += 1) {
embeddings.push(batchResponse[j]);
}
}
return embeddings;
}
/**
* Embeds a query with Jina retrieval-query task parameters.
*/
async embedQuery(input: JinaEmbeddingsInput): Promise<number[]> {
const params = this.getParams(this.doStripNewLines([input]), true);
const embeddings = (await this.embeddingWithRetry(params)) || [[]];
return embeddings[0];
}
/**
* Removes newlines from string inputs when configured to match upstream Jina behavior.
*/
private doStripNewLines(input: JinaEmbeddingsInput[]): JinaEmbeddingsInput[] {
if (this.stripNewLines) {
return input.map((item) => {
if (typeof item === "string") {
return item.replace(/\n/g, " ");
}
if (item.text) {
return { text: item.text.replace(/\n/g, " ") };
}
return item;
});
}
return input;
}
/**
* Builds the request body for Jina's retrieval embedding API.
*/
private getParams(input: JinaEmbeddingsInput[], query?: boolean): EmbeddingCreateParams {
return {
model: this.model,
input,
dimensions: this.dimensions,
task: query ? "retrieval.query" : "retrieval.passage",
normalized: this.normalized,
};
}
/**
* Sends a single embeddings request and returns vectors in response order.
*/
private async embeddingWithRetry(body: EmbeddingCreateParams): Promise<number[][]> {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify(body),
});
const embeddingData: EmbeddingResponse | EmbeddingErrorResponse = await response.json();
if ("detail" in embeddingData && embeddingData.detail) {
throw new Error(`${embeddingData.detail}`);
}
return (embeddingData as EmbeddingResponse).data.map(({ embedding }) => embedding);
}
}

View file

@ -1,9 +1,10 @@
import { safeFetchNoThrow } from "@/utils";
import { OpenAIEmbeddings } from "@langchain/openai";
export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
private customConfig: any;
private customConfig: Record<string, unknown>;
constructor(config: any) {
constructor(config: Record<string, unknown>) {
super(config);
// Store the config for our custom methods
this.customConfig = config;
@ -29,16 +30,20 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
};
// Get the correct baseURL, apiKey, and fetch function from the configuration
const baseURL = this.customConfig.configuration?.baseURL || "https://api.openai.com/v1";
const configuration = this.customConfig.configuration as
| { baseURL?: string; fetch?: typeof fetch }
| undefined;
const baseURL = configuration?.baseURL || "https://api.openai.com/v1";
const url = `${baseURL}/embeddings`;
const apiKey = this.customConfig.apiKey;
const fetchFn = this.customConfig.configuration?.fetch || fetch;
const apiKey = this.customConfig.apiKey as string;
const fetchFn = configuration?.fetch || safeFetchNoThrow;
const response = await fetchFn(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...((this.customConfig.headers as Record<string, string>) || {}),
},
body: JSON.stringify(requestBody),
});
@ -50,17 +55,17 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
);
}
const responseData = await response.json();
const responseData = (await response.json()) as { data?: Array<{ embedding?: unknown }> };
if (!responseData.data || !Array.isArray(responseData.data)) {
throw new Error("Invalid API response format: missing or invalid data array");
}
return responseData.data.map((item: any) => {
return responseData.data.map((item) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error("Invalid API response format: missing or invalid embedding array");
}
return item.embedding;
return item.embedding as number[];
});
}
}

View file

@ -1,23 +1,88 @@
import { BREVILABS_API_BASE_URL } from "@/constants";
import { getDecryptedKey } from "@/encryptionService";
import { MissingPlusLicenseError } from "@/error";
import { logInfo } from "@/logger";
import { turnOffPlus, turnOnPlus } from "@/plusUtils";
import { getSettings } from "@/settings/model";
import { arrayBufferToBase64 } from "@/utils/base64";
import { Notice } from "obsidian";
import { requestUrl } from "obsidian";
export interface BrocaResponse {
response: {
tool_calls: Array<{
tool: string;
args: {
[key: string]: any;
};
}>;
salience_terms: string[];
/**
* Build a multipart/form-data body buffer from a FormData instance.
* Returned as an ArrayBuffer suitable for passing to Obsidian's requestUrl.
*
* @param formData - FormData containing strings and/or File/Blob entries.
* @returns The serialized multipart body and the Content-Type header (including boundary).
*/
async function buildMultipartFromFormData(
formData: FormData
): Promise<{ body: ArrayBuffer; contentType: string }> {
const boundary = `----CopilotBoundary${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
const encoder = new TextEncoder();
const parts: Uint8Array[] = [];
for (const [name, value] of formData.entries()) {
parts.push(encoder.encode(`--${boundary}\r\n`));
if (value instanceof Blob) {
const filename = value instanceof File ? value.name : "blob";
const contentType = value.type || "application/octet-stream";
parts.push(
encoder.encode(
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
`Content-Type: ${contentType}\r\n\r\n`
)
);
const buf = await value.arrayBuffer();
parts.push(new Uint8Array(buf));
parts.push(encoder.encode("\r\n"));
} else {
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
parts.push(encoder.encode(String(value)));
parts.push(encoder.encode("\r\n"));
}
}
parts.push(encoder.encode(`--${boundary}--\r\n`));
const totalLength = parts.reduce((sum, p) => sum + p.byteLength, 0);
const out = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.byteLength;
}
return {
body: out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength),
contentType: `multipart/form-data; boundary=${boundary}`,
};
elapsed_time_ms: number;
detail?: string;
}
/**
* Normalize a requestUrl response into the {data, error} shape used by Brevilabs API methods.
* Handles the case where `response.json` is a raw string (non-JSON body, e.g. HTML error page).
*/
function parseBrevilabsResponse<T>(
response: { status: number; json: unknown },
endpoint: string
): { data: T | null; error?: Error } {
let data: unknown = response.json;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch {
// Non-JSON body — fall through to status-based error.
}
}
if (response.status < 200 || response.status >= 300) {
const detail = (data as { detail?: { reason?: string; error?: string } } | null)?.detail;
if (detail?.reason) {
const error = new Error(detail.reason);
if (detail.error) error.name = detail.error;
return { data: null, error };
}
return { data: null, error: new Error(`HTTP error: ${response.status}`) };
}
logInfo(`[API ${endpoint} request]:`, data);
return { data: data as T };
}
export interface RerankResponse {
@ -36,22 +101,22 @@ export interface RerankResponse {
}
export interface ToolCall {
tool: any;
args: any;
tool: unknown;
args: unknown;
}
export interface Url4llmResponse {
response: any;
response: string;
elapsed_time_ms: number;
}
export interface Pdf4llmResponse {
response: any;
response: string;
elapsed_time_ms: number;
}
export interface Docs4llmResponse {
response: any;
response: unknown;
elapsed_time_ms: number;
}
@ -76,25 +141,16 @@ export interface Youtube4llmResponse {
elapsed_time_ms: number;
}
export interface Twitter4llmResponse {
response: string;
elapsed_time_ms: number;
}
export interface LicenseResponse {
is_valid: boolean;
plan: string;
}
export interface AutocompleteResponse {
response: {
completion: string;
};
elapsed_time_ms: number;
}
export interface WordCompleteResponse {
response: {
selected_word: string;
};
elapsed_time_ms: number;
}
export class BrevilabsClient {
private static instance: BrevilabsClient;
private pluginVersion: string = "Unknown";
@ -108,10 +164,9 @@ export class BrevilabsClient {
private checkLicenseKey() {
if (!getSettings().plusLicenseKey) {
new Notice(
throw new MissingPlusLicenseError(
"Copilot Plus license key not found. Please enter your license key in the settings."
);
throw new Error("License key not initialized");
}
}
@ -121,7 +176,7 @@ export class BrevilabsClient {
private async makeRequest<T>(
endpoint: string,
body: any,
body: Record<string, unknown>,
method = "POST",
excludeAuthHeader = false,
skipLicenseCheck = false
@ -139,31 +194,21 @@ export class BrevilabsClient {
url.searchParams.append(key, value as string);
});
}
const response = await fetch(url.toString(), {
method,
headers: {
"Content-Type": "application/json",
...(!excludeAuthHeader && {
Authorization: `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`,
}),
"X-Client-Version": this.pluginVersion,
},
...(method === "POST" && { body: JSON.stringify(body) }),
});
const data = await response.json();
if (!response.ok) {
try {
const errorDetail = data.detail;
const error = new Error(errorDetail.reason);
error.name = errorDetail.error;
return { data: null, error };
} catch {
return { data: null, error: new Error("Unknown error") };
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Client-Version": this.pluginVersion,
};
if (!excludeAuthHeader) {
headers.Authorization = `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`;
}
logInfo(`[API ${endpoint} request]:`, data);
return { data };
const response = await requestUrl({
url: url.toString(),
method,
headers,
...(method === "POST" && { body: JSON.stringify(body) }),
throw: false,
});
return parseBrevilabsResponse<T>(response, endpoint);
}
private async makeFormDataRequest<T>(
@ -181,29 +226,21 @@ export class BrevilabsClient {
const url = new URL(`${BREVILABS_API_BASE_URL}${endpoint}`);
try {
const response = await fetch(url.toString(), {
// Build multipart body manually for requestUrl (does not natively support FormData).
const { body, contentType } = await buildMultipartFromFormData(formData);
const response = await requestUrl({
url: url.toString(),
method: "POST",
headers: {
// No Content-Type header - browser will set it automatically with boundary
"Content-Type": contentType,
Authorization: `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`,
"X-Client-Version": this.pluginVersion,
},
body: formData,
body,
throw: false,
});
const data = await response.json();
if (!response.ok) {
try {
const errorDetail = data.detail;
const error = new Error(errorDetail.reason);
error.name = errorDetail.error;
return { data: null, error };
} catch {
return { data: null, error: new Error(`HTTP error: ${response.status}`) };
}
}
logInfo(`[API ${endpoint} form-data request]:`, data);
return { data };
return parseBrevilabsResponse<T>(response, `${endpoint} form-data`);
} catch (error) {
return { data: null, error: error instanceof Error ? error : new Error(String(error)) };
}
@ -216,10 +253,10 @@ export class BrevilabsClient {
* unknown error.
*/
async validateLicenseKey(
context?: Record<string, any>
context?: Record<string, unknown>
): Promise<{ isValid: boolean | undefined; plan?: string }> {
// Build the request body with proper structure
const requestBody: Record<string, any> = {
const requestBody: Record<string, unknown> = {
license_key: await getDecryptedKey(getSettings().plusLicenseKey),
};
@ -262,21 +299,6 @@ export class BrevilabsClient {
return { isValid: true, plan: data?.plan };
}
async broca(userMessage: string, isProjectMode: boolean): Promise<BrocaResponse> {
const { data, error } = await this.makeRequest<BrocaResponse>("/broca", {
message: userMessage,
is_project_mode: isProjectMode,
});
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from broca");
}
return data;
}
async rerank(query: string, documents: string[]): Promise<RerankResponse> {
const { data, error } = await this.makeRequest<RerankResponse>("/rerank", {
query,
@ -417,42 +439,15 @@ export class BrevilabsClient {
return data;
}
async autocomplete(
prefix: string,
noteContext: string = "",
relevant_notes: string = ""
): Promise<AutocompleteResponse> {
const { data, error } = await this.makeRequest<AutocompleteResponse>("/autocomplete", {
prompt: prefix,
note_context: noteContext,
relevant_notes: relevant_notes,
max_tokens: 64,
});
async twitter4llm(url: string): Promise<Twitter4llmResponse> {
const { data, error } = await this.makeRequest<Twitter4llmResponse>("/twitter4llm", { url });
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from autocomplete");
throw new Error("No data returned from twitter4llm");
}
return data;
}
async wordcomplete(
prefix: string,
suffix: string = "",
suggestions: string[]
): Promise<WordCompleteResponse> {
const { data, error } = await this.makeRequest<WordCompleteResponse>("/wordcomplete", {
prefix: prefix,
suffix: suffix,
suggestions: suggestions,
});
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from wordcomplete");
}
return data;
}
}

View file

@ -1,11 +1,5 @@
import {
getChainType,
getCurrentProject,
getModelKey,
SetChainOptions,
setChainType,
} from "@/aiParams";
import ChainFactory, { ChainType, Document } from "@/chainFactory";
import { getChainType, getCurrentProject, getModelKey, SetChainOptions } from "@/aiParams";
import { ChainType } from "@/chainType";
import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants";
import {
AutonomousAgentChainRunner,
@ -16,25 +10,24 @@ import {
VaultQAChainRunner,
} from "@/LLMProviders/chainRunner/index";
import { logError, logInfo } from "@/logger";
import { getSettings, getSystemPrompt, subscribeToSettingsChange } from "@/settings/model";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { getSystemPrompt } from "@/system-prompts/systemPromptBuilder";
import { ChatMessage } from "@/types/message";
import { findCustomModel, isOSeriesModel, isSupportedChain } from "@/utils";
import { findCustomModel, isOSeriesModel } from "@/utils";
import { MissingModelKeyError } from "@/error";
import {
ChatPromptTemplate,
HumanMessagePromptTemplate,
MessagesPlaceholder,
} from "@langchain/core/prompts";
import { RunnableSequence } from "@langchain/core/runnables";
import { Document } from "@langchain/core/documents";
import { App, Notice } from "obsidian";
import ChatModelManager from "./chatModelManager";
import MemoryManager from "./memoryManager";
import PromptManager from "./promptManager";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
export default class ChainManager {
// TODO: These chains are deprecated since we now use direct chat model calls in chain runners
// Consider removing after verifying no dependencies remain
private chain: RunnableSequence;
private retrievalChain: RunnableSequence;
private retrievedDocuments: Document[] = [];
public getRetrievedDocuments(): Document[] {
@ -45,6 +38,8 @@ export default class ChainManager {
public chatModelManager: ChatModelManager;
public memoryManager: MemoryManager;
public promptManager: PromptManager;
public userMemoryManager: UserMemoryManager;
private pendingModelError: Error | null = null;
constructor(app: App) {
// Instantiate singletons
@ -52,12 +47,15 @@ export default class ChainManager {
this.memoryManager = MemoryManager.getInstance();
this.chatModelManager = ChatModelManager.getInstance();
this.promptManager = PromptManager.getInstance();
this.userMemoryManager = new UserMemoryManager(app);
// Initialize async operations
this.initialize();
void this.initialize().catch((err) => logError("ChainManager initialize failed", err));
subscribeToSettingsChange(async () => {
await this.createChainWithNewModel();
subscribeToSettingsChange(() => {
void this.createChainWithNewModel().catch((err) =>
logError("createChainWithNewModel failed", err)
);
});
}
@ -65,36 +63,19 @@ export default class ChainManager {
await this.createChainWithNewModel();
}
// TODO: These methods are deprecated - chain runners now use direct chat model calls
// Remove after confirming no usage remains
public getChain(): RunnableSequence {
return this.chain;
}
public getRetrievalChain(): RunnableSequence {
return this.retrievalChain;
}
private validateChainType(chainType: ChainType): void {
if (chainType === undefined || chainType === null) throw new Error("No chain type set");
}
private validateChatModel() {
if (this.pendingModelError) {
throw this.pendingModelError;
}
if (!this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
const errorMsg =
"Chat model is not initialized properly, check your API key in Copilot setting and make sure you have API access.";
new Notice(errorMsg);
throw new Error(errorMsg);
}
}
// TODO: This method is deprecated - chain validation no longer needed
// Remove after confirming no dependencies
private validateChainInitialization() {
if (!this.chain || !isSupportedChain(this.chain)) {
console.error("Chain is not initialized properly, re-initializing chain: ", getChainType());
this.createChainWithNewModel({}, false);
// this.setChain(getChainType());
throw new MissingModelKeyError(errorMsg);
}
}
@ -110,6 +91,7 @@ export default class ChainManager {
options: SetChainOptions = {},
neededReInitChatMode: boolean = true
): Promise<void> {
let newModelKey: string | undefined;
const chainType = getChainType();
const currentProject = getCurrentProject();
@ -117,15 +99,14 @@ export default class ChainManager {
return;
}
let newModelKey =
chainType === ChainType.PROJECT_CHAIN ? currentProject?.projectModelKey : getModelKey();
if (!newModelKey) {
new Notice("No model key found");
throw new Error("No model key found");
}
try {
newModelKey =
chainType === ChainType.PROJECT_CHAIN ? currentProject?.projectModelKey : getModelKey();
if (!newModelKey) {
throw new MissingModelKeyError("No model key found. Please select a model in settings.");
}
if (neededReInitChatMode) {
let customModel = findCustomModel(newModelKey, getSettings().activeModels);
if (!customModel) {
@ -159,119 +140,31 @@ export default class ChainManager {
...currentProject?.modelConfigs,
};
await this.chatModelManager.setChatModel(mergedModel);
this.pendingModelError = null;
}
// Must update the chatModel for chain because ChainFactory always
// retrieves the old chain without the chatModel change if it exists!
// Create a new chain with the new chatModel
this.setChain(chainType, options);
// Chain-type housekeeping. Do NOT write `chainType` back to the atom —
// the atom is owned by the UI dropdowns and `applyPlusSettings`. The
// captured local `chainType` may already be stale by the time we reach
// here (we just awaited `setChatModel(...)`), and writing it back used
// to create a self-sustaining `setChainType` → ProjectManager
// subscriber → `createChainWithNewModel` loop that froze Obsidian on
// apply-Plus-key.
if (this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
this.validateChainType(chainType);
if (options.refreshIndex) {
await this.refreshVaultIndex();
}
} else {
console.error(
"createChainWithNewModel: skipping chain-type housekeeping — no chat model set."
);
}
logInfo(`Setting model to ${newModelKey}`);
} catch (error) {
this.pendingModelError = error instanceof Error ? error : new Error(String(error));
logError(`createChainWithNewModel failed: ${error}`);
logInfo(`modelKey: ${newModelKey}`);
}
}
// TODO: This method is deprecated - chain runners now handle chain logic directly
// Remove after confirming no usage remains
async setChain(chainType: ChainType, options: SetChainOptions = {}): Promise<void> {
if (!this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
console.error("setChain failed: No chat model set.");
return;
}
this.validateChainType(chainType);
// Get chatModel, memory, prompt, and embeddingAPI from respective managers
const chatModel = this.chatModelManager.getChatModel();
const memory = this.memoryManager.getMemory();
const chatPrompt = this.promptManager.getChatPrompt();
switch (chainType) {
case ChainType.LLM_CHAIN: {
// TODO: LLMChainRunner now handles this directly without chains
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
setChainType(ChainType.LLM_CHAIN);
break;
}
case ChainType.VAULT_QA_CHAIN: {
// TODO: VaultQAChainRunner now handles this directly without chains
await this.initializeQAChain(options);
// Create retriever based on semantic search setting
const settings = getSettings();
const retriever = settings.enableSemanticSearchV3
? new (await import("@/search/hybridRetriever")).HybridRetriever({
minSimilarityScore: 0.01,
maxK: settings.maxSourceChunks,
salientTerms: [],
})
: new (await import("@/search/v3/TieredLexicalRetriever")).TieredLexicalRetriever(app, {
minSimilarityScore: 0.01,
maxK: settings.maxSourceChunks,
salientTerms: [],
timeRange: undefined,
textWeight: undefined,
returnAll: false,
useRerankerThreshold: undefined,
});
// Create new conversational retrieval chain
this.retrievalChain = ChainFactory.createConversationalRetrievalChain(
{
llm: chatModel,
retriever: retriever,
systemMessage: getSystemPrompt(),
},
this.storeRetrieverDocuments.bind(this),
getSettings().debug
);
setChainType(ChainType.VAULT_QA_CHAIN);
if (getSettings().debug) {
console.log("New Vault QA chain with hybrid retriever created for entire vault");
console.log("Set chain:", ChainType.VAULT_QA_CHAIN);
}
break;
}
case ChainType.COPILOT_PLUS_CHAIN: {
// For initial load of the plugin
await this.initializeQAChain(options);
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
setChainType(ChainType.COPILOT_PLUS_CHAIN);
break;
}
case ChainType.PROJECT_CHAIN: {
// For initial load of the plugin
await this.initializeQAChain(options);
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
setChainType(ChainType.PROJECT_CHAIN);
break;
}
default:
this.validateChainType(chainType);
break;
logInfo(`modelKey: ${newModelKey || getModelKey()}`);
}
}
@ -293,21 +186,19 @@ export default class ChainManager {
case ChainType.PROJECT_CHAIN:
return new ProjectChainRunner(this);
default:
throw new Error(`Unsupported chain type: ${chainType}`);
throw new Error(`Unsupported chain type: ${String(chainType)}`);
}
}
private async initializeQAChain(options: SetChainOptions) {
// Handle index refresh if needed
if (options.refreshIndex) {
const settings = getSettings();
if (settings.enableSemanticSearchV3) {
// Use VectorStoreManager for Orama indexing
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().indexVaultToVectorStore(false);
}
// V3 search builds indexes on demand, no action needed
}
/**
* Re-index the vault into the Orama vector store. No-op when legacy
* semantic search is disabled v3 lexical search builds its index on
* demand and doesn't need a precomputed store.
*/
private async refreshVaultIndex() {
if (!getSettings().enableSemanticSearchV3) return;
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().indexVaultToVectorStore(false);
}
async runChain(
@ -323,10 +214,13 @@ export default class ChainManager {
) {
const { ignoreSystemMessage = false } = options;
logInfo("Step 0: Initial user message:\n", userMessage);
const l5Text = userMessage.contextEnvelope?.layers.find((l) => l.id === "L5_USER")?.text;
logInfo(
"Step 0: Initial user message:\n",
l5Text || userMessage.originalMessage || userMessage.message
);
this.validateChatModel();
this.validateChainInitialization();
const chatModel = this.chatModelManager.getChatModel();
@ -346,7 +240,9 @@ export default class ChainManager {
]);
}
this.createChainWithNewModel({ prompt: effectivePrompt }, false);
void this.createChainWithNewModel({ prompt: effectivePrompt }, false).catch((err) =>
logError("createChainWithNewModel failed", err)
);
/*this.setChain(getChainType(), {
prompt: effectivePrompt,
});*/
@ -361,17 +257,4 @@ export default class ChainManager {
options
);
}
async updateMemoryWithLoadedMessages(messages: ChatMessage[]) {
await this.memoryManager.clearChatMemory();
for (let i = 0; i < messages.length; i += 2) {
const userMsg = messages[i];
const aiMsg = messages[i + 1];
if (userMsg && aiMsg && userMsg.sender === USER_SENDER) {
await this.memoryManager
.getMemory()
.saveContext({ input: userMsg.message }, { output: aiMsg.message });
}
}
}
}

View file

@ -0,0 +1,262 @@
import {
buildToolCallsFromChunks,
accumulateToolCallChunk,
ToolCallChunk,
} from "./utils/nativeToolCalling";
/**
* Test suite for Gemini tool call name extraction fix (Issue #2233)
*
* Root cause: Gemini's @langchain/google-genai nests tool call names inside
* `functionCall.name` instead of at the top level `name` property. Without
* the fallback, all Gemini tool call names are empty, causing
* buildToolCallsFromChunks to skip them treated as "no tool calls"
* empty response since thinking tokens were filtered.
*/
describe("accumulateToolCallChunk", () => {
describe("OpenAI-format chunks (top-level name)", () => {
it("should accumulate name from top-level tc.name", () => {
const chunks = new Map<number, ToolCallChunk>();
accumulateToolCallChunk(chunks, {
index: 0,
id: "call_123",
name: "localSearch",
args: '{"query":',
});
accumulateToolCallChunk(chunks, {
index: 0,
args: '"test"}',
});
const result = chunks.get(0)!;
expect(result.name).toBe("localSearch");
expect(result.id).toBe("call_123");
expect(result.args).toBe('{"query":"test"}');
});
it("should handle multiple concurrent tool calls", () => {
const chunks = new Map<number, ToolCallChunk>();
accumulateToolCallChunk(chunks, { index: 0, name: "localSearch", args: '{"q":"a"}' });
accumulateToolCallChunk(chunks, { index: 1, name: "readNote", args: '{"path":"b"}' });
expect(chunks.get(0)!.name).toBe("localSearch");
expect(chunks.get(1)!.name).toBe("readNote");
});
});
describe("Gemini-format chunks (name in functionCall)", () => {
it("should extract name from functionCall.name when top-level name is missing", () => {
const chunks = new Map<number, ToolCallChunk>();
// Gemini sends chunks with functionCall.name instead of top-level name
accumulateToolCallChunk(chunks, {
index: 0,
id: "call_456",
functionCall: { name: "localSearch" },
args: '{"query":"test"}',
});
const result = chunks.get(0)!;
expect(result.name).toBe("localSearch");
expect(result.id).toBe("call_456");
expect(result.args).toBe('{"query":"test"}');
});
it("should handle multiple Gemini tool calls", () => {
const chunks = new Map<number, ToolCallChunk>();
accumulateToolCallChunk(chunks, {
index: 0,
functionCall: { name: "localSearch" },
args: '{"query":"piano"}',
});
accumulateToolCallChunk(chunks, {
index: 1,
functionCall: { name: "readNote" },
args: '{"path":"notes/music.md"}',
});
expect(chunks.get(0)!.name).toBe("localSearch");
expect(chunks.get(1)!.name).toBe("readNote");
});
it("should prefer top-level name over functionCall.name", () => {
const chunks = new Map<number, ToolCallChunk>();
accumulateToolCallChunk(chunks, {
index: 0,
name: "topLevel",
functionCall: { name: "nested" },
args: "{}",
});
// Top-level name takes priority via nullish coalescing (??)
expect(chunks.get(0)!.name).toBe("topLevel");
});
});
describe("Edge cases", () => {
it("should default index to 0 when not provided", () => {
const chunks = new Map<number, ToolCallChunk>();
accumulateToolCallChunk(chunks, { name: "localSearch", args: "{}" });
expect(chunks.has(0)).toBe(true);
expect(chunks.get(0)!.name).toBe("localSearch");
});
it("should handle chunk with no name at all", () => {
const chunks = new Map<number, ToolCallChunk>();
accumulateToolCallChunk(chunks, { index: 0, args: '{"query":"test"}' });
expect(chunks.get(0)!.name).toBe("");
expect(chunks.get(0)!.args).toBe('{"query":"test"}');
});
it("should accumulate args across multiple chunks", () => {
const chunks = new Map<number, ToolCallChunk>();
accumulateToolCallChunk(chunks, { index: 0, name: "localSearch", args: '{"qu' });
accumulateToolCallChunk(chunks, { index: 0, args: 'ery":' });
accumulateToolCallChunk(chunks, { index: 0, args: '"test"}' });
expect(chunks.get(0)!.args).toBe('{"query":"test"}');
});
});
});
describe("buildToolCallsFromChunks", () => {
it("should build tool calls from properly accumulated chunks", () => {
const chunks = new Map<number, ToolCallChunk>();
chunks.set(0, { id: "call_1", name: "localSearch", args: '{"query":"test"}' });
const result = buildToolCallsFromChunks(chunks);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("localSearch");
expect(result[0].args).toEqual({ query: "test" });
expect(result[0].id).toBe("call_1");
});
it("should skip chunks with no name (the bug this fix addresses)", () => {
const chunks = new Map<number, ToolCallChunk>();
// This is what happened before the fix: Gemini chunks had no name
// because the accumulator didn't check functionCall.name
chunks.set(0, { name: "", args: '{"query":"test"}' });
const result = buildToolCallsFromChunks(chunks);
// Empty name → skipped → no tool calls → treated as final response
expect(result).toHaveLength(0);
});
it("should handle multiple tool calls", () => {
const chunks = new Map<number, ToolCallChunk>();
chunks.set(0, { id: "call_1", name: "localSearch", args: '{"query":"piano"}' });
chunks.set(1, { id: "call_2", name: "readNote", args: '{"path":"notes/music.md"}' });
const result = buildToolCallsFromChunks(chunks);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("localSearch");
expect(result[1].name).toBe("readNote");
});
it("should generate an ID when chunk has no id", () => {
const chunks = new Map<number, ToolCallChunk>();
chunks.set(0, { name: "localSearch", args: '{"query":"test"}' });
const result = buildToolCallsFromChunks(chunks);
expect(result).toHaveLength(1);
expect(result[0].id).toMatch(/^call_/);
});
it("should handle malformed JSON args gracefully", () => {
const chunks = new Map<number, ToolCallChunk>();
chunks.set(0, { name: "localSearch", args: "not valid json" });
const result = buildToolCallsFromChunks(chunks);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("localSearch");
expect(result[0].args).toEqual({});
});
it("should handle empty args", () => {
const chunks = new Map<number, ToolCallChunk>();
chunks.set(0, { name: "localSearch", args: "" });
const result = buildToolCallsFromChunks(chunks);
expect(result).toHaveLength(1);
expect(result[0].args).toEqual({});
});
});
describe("End-to-end: Gemini streaming → buildToolCallsFromChunks", () => {
it("should correctly process Gemini-format chunks through the full pipeline", () => {
const chunks = new Map<number, ToolCallChunk>();
// Simulate Gemini streaming: name comes via functionCall, not top-level
accumulateToolCallChunk(chunks, {
index: 0,
id: "call_gemini_1",
functionCall: { name: "localSearch" },
args: '{"query":"piano notes"}',
});
const result = buildToolCallsFromChunks(chunks);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("localSearch");
expect(result[0].args).toEqual({ query: "piano notes" });
});
it("should correctly process OpenAI-format chunks through the full pipeline", () => {
const chunks = new Map<number, ToolCallChunk>();
// Simulate OpenAI streaming: name at top level
accumulateToolCallChunk(chunks, {
index: 0,
id: "call_openai_1",
name: "localSearch",
args: '{"query":"piano notes"}',
});
const result = buildToolCallsFromChunks(chunks);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("localSearch");
expect(result[0].args).toEqual({ query: "piano notes" });
});
it("should handle sequential Gemini tool calls (the failing scenario)", () => {
const chunks = new Map<number, ToolCallChunk>();
// This is the exact scenario that was failing:
// Gemini 3.1 Pro returns 2 sequential tool calls, but names were dropped
accumulateToolCallChunk(chunks, {
index: 0,
id: "call_g1",
functionCall: { name: "localSearch" },
args: '{"query":"search term"}',
});
accumulateToolCallChunk(chunks, {
index: 1,
id: "call_g2",
functionCall: { name: "readNote" },
args: '{"path":"some/note.md"}',
});
const result = buildToolCallsFromChunks(chunks);
// Both tool calls should be preserved — before the fix, both were dropped
expect(result).toHaveLength(2);
expect(result[0].name).toBe("localSearch");
expect(result[1].name).toBe("readNote");
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
import { ABORT_REASON, AI_SENDER } from "@/constants";
import { logError, logInfo } from "@/logger";
import { ChatMessage } from "@/types/message";
import { ChatMessage, ResponseMetadata } from "@/types/message";
import { err2String, formatDateTime } from "@/utils";
import { Notice } from "obsidian";
import ChainManager from "../chainManager";
export interface ChainRunner {
@ -38,6 +37,19 @@ export abstract class BaseChainRunner implements ChainRunner {
}
): Promise<string>;
/**
* Handles a completed LLM response by saving conversation memory, updating the chat history, and logging summary details.
*
* @param fullAIResponse - The final response content to present.
* @param userMessage - The originating user message.
* @param abortController - Abort controller used to track cancellation reasons.
* @param addMessage - Callback to append a message to the UI state.
* @param updateCurrentAiMessage - Callback to update the streaming message placeholder.
* @param sources - Optional sources associated with the response.
* @param llmFormattedOutput - Optional formatted output string for memory storage.
* @param responseMetadata - Optional metadata describing truncation or token usage.
* @returns The full AI response text.
*/
protected async handleResponse(
fullAIResponse: string,
userMessage: ChatMessage,
@ -45,30 +57,51 @@ export abstract class BaseChainRunner implements ChainRunner {
addMessage: (message: ChatMessage) => void,
updateCurrentAiMessage: (message: string) => void,
sources?: { title: string; path: string; score: number }[],
llmFormattedOutput?: string
llmFormattedOutput?: string,
responseMetadata?: ResponseMetadata
) {
// Save to memory and add message if we have a response
// Skip only if it's a NEW_CHAT abort (clearing everything)
if (
fullAIResponse &&
!(abortController.signal.aborted && abortController.signal.reason === ABORT_REASON.NEW_CHAT)
) {
// Use saveContext for atomic operation and proper memory management
// Note: LangChain's memory expects text content, not multimodal arrays, so multimodal content is not saved
await this.chainManager.memoryManager
.getMemory()
.saveContext(
{ input: userMessage.message },
{ output: llmFormattedOutput || fullAIResponse }
);
addMessage({
message: fullAIResponse,
// Add message if we have a response OR if response was truncated (even if empty)
// This ensures truncation warnings are shown even for empty truncated responses
const shouldAddMessage =
(fullAIResponse || responseMetadata?.wasTruncated) &&
!(abortController.signal.aborted && abortController.signal.reason === ABORT_REASON.NEW_CHAT);
if (shouldAddMessage) {
// Save the expanded user message (L5) to memory — NOT the full processedText.
// processedText includes context artifact XML (L3), which already lives in the
// envelope's L2/L3 layers. Baking it into L4 chat history would cause
// triple-inclusion and waste tokens.
// L5 preserves prompt-expanded content (e.g. {include_note_content} placeholders)
// while excluding context artifact blocks.
const l5Text = userMessage.contextEnvelope?.layers.find((l) => l.id === "L5_USER")?.text;
const inputForMemory = l5Text || userMessage.originalMessage || userMessage.message;
const outputForMemory =
llmFormattedOutput || fullAIResponse || "[Response truncated - no content generated]";
await this.chainManager.memoryManager.saveContext(
{ input: inputForMemory },
{ output: outputForMemory }
);
// For empty truncated responses, show a helpful message
const displayMessage =
fullAIResponse ||
(responseMetadata?.wasTruncated
? "_[The response was truncated before any content could be generated. Try increasing the max tokens limit.]_"
: "");
const messageToAdd = {
message: displayMessage,
sender: AI_SENDER,
isVisible: true,
timestamp: formatDateTime(new Date()),
sources: sources,
});
responseMetadata: responseMetadata,
};
addMessage(messageToAdd);
// Clear the streaming message since it's now in chat history
updateCurrentAiMessage("");
@ -77,8 +110,9 @@ export abstract class BaseChainRunner implements ChainRunner {
updateCurrentAiMessage("");
}
// Log compact memory summary and a truncated final response (~300 chars)
const historyMessages = (this.chainManager.memoryManager.getMemory().chatHistory as any)
.messages;
const historyMessages = (
this.chainManager.memoryManager.getMemory().chatHistory as { messages?: unknown[] }
).messages;
logInfo("Chat memory updated:\n", {
turns: Array.isArray(historyMessages) ? historyMessages.length : 0,
});
@ -87,8 +121,8 @@ export abstract class BaseChainRunner implements ChainRunner {
try {
const { parseToolCallMarkers } = await import("./utils/toolCallParser");
const parsed = parseToolCallMarkers(fullAIResponse);
let textOnly = parsed.segments
.map((seg: any) => (seg.type === "text" ? seg.content : ""))
let textOnly = (parsed.segments as { type: string; content: string }[])
.map((seg) => (seg.type === "text" ? seg.content : ""))
.join("")
.trim();
if (!textOnly) textOnly = fullAIResponse || "";
@ -107,19 +141,22 @@ export abstract class BaseChainRunner implements ChainRunner {
return fullAIResponse;
}
protected async handleError(
error: any,
addMessage?: (message: ChatMessage) => void,
updateCurrentAiMessage?: (message: string) => void
) {
/**
* Logs provider errors and streams a user-friendly message to the UI.
*
* @param error - Raw provider error object.
* @param processErrorChunk - Callback used to stream error text to the UI.
*/
protected async handleError(error: unknown, processErrorChunk: (message: string) => void) {
const msg = err2String(error);
logError("Error during LLM invocation:", msg);
const errorData = error?.response?.data?.error || msg;
const errorCode = errorData?.code || msg;
const errorData =
(error as { response?: { data?: { error?: unknown } } })?.response?.data?.error || msg;
const errorCode = (errorData as { code?: string })?.code || msg;
let errorMessage = "";
// Check for specific error messages
if (error?.message?.includes("Invalid license key")) {
if ((error as { message?: string })?.message?.includes("Invalid license key")) {
errorMessage = "Invalid Copilot Plus license key. Please check your license key in settings.";
} else if (errorCode === "model_not_found") {
errorMessage =
@ -129,35 +166,79 @@ export abstract class BaseChainRunner implements ChainRunner {
}
logError(errorData);
processErrorChunk(this.enhancedErrorMsg(errorMessage, msg, error));
}
if (addMessage && updateCurrentAiMessage) {
updateCurrentAiMessage("");
/**
* Builds an enhanced user-facing error message that includes targeted guidance when authentication failures are detected.
*
* @param errorMessage - The provider-specific error message or code.
* @param msg - Normalized error string used for diagnostics.
* @param error - Raw error object returned from the provider SDK.
* @returns A formatted error string suitable for streaming to the UI.
*/
private enhancedErrorMsg(errorMessage: string, msg: string, error: unknown) {
// remove langchain troubleshooting URL from error message
const ignoreEndIndex = errorMessage.search("Troubleshooting URL");
errorMessage = ignoreEndIndex !== -1 ? errorMessage.slice(0, ignoreEndIndex) : errorMessage;
// remove langchain troubleshooting URL from error message
const ignoreEndIndex = errorMessage.search("Troubleshooting URL");
errorMessage = ignoreEndIndex !== -1 ? errorMessage.slice(0, ignoreEndIndex) : errorMessage;
// add more user guide for invalid API key
if (msg.search(/401|invalid|not valid/gi) !== -1) {
errorMessage =
"Something went wrong. Please check if you have set your API key." +
"\nPath: Settings > copilot plugin > Basic Tab > Set Keys." +
"\nOr check model config" +
"\nError Details: " +
errorMessage;
}
addMessage({
message: errorMessage,
isErrorMessage: true,
sender: AI_SENDER,
isVisible: true,
timestamp: formatDateTime(new Date()),
});
} else {
// Fallback to Notice if message handlers aren't provided
new Notice(errorMessage);
logError(errorData);
// add more user guide for invalid API key
if (this.isAuthenticationError(error, msg)) {
errorMessage =
"Something went wrong. Please check if you have set your API key." +
"\nPath: Settings > copilot plugin > Basic Tab > Set Keys." +
"\nOr check model config" +
"\nError Details: " +
errorMessage;
}
return errorMessage;
}
/**
* Determines whether an error is likely related to authentication or missing API credentials.
*
* @param error - Raw provider error object.
* @param normalizedMessage - Fallback message string for heuristic matching.
* @returns True if the error indicates an authentication problem.
*/
private isAuthenticationError(error: unknown, normalizedMessage: string): boolean {
const responseError = (
error as {
response?: {
status?: number;
data?: {
error?: { status?: number | string; code?: string; message?: string; type?: string };
};
};
}
)?.response;
const errorData = responseError?.data?.error ?? (error as { error?: unknown })?.error;
const rawStatus = responseError?.status ?? (errorData as { status?: number | string })?.status;
const statusCode = typeof rawStatus === "string" ? Number.parseInt(rawStatus, 10) : rawStatus;
const errorObject =
typeof errorData === "object" && errorData !== null
? (errorData as Record<string, unknown>)
: undefined;
const loweredMessage = (
typeof errorObject?.message === "string" ? errorObject.message : normalizedMessage
).toLowerCase();
const loweredCode = typeof errorObject?.code === "string" ? errorObject.code.toLowerCase() : "";
const loweredType = typeof errorObject?.type === "string" ? errorObject.type.toLowerCase() : "";
if (statusCode === 401) {
return true;
}
const authHints = [
"api key",
"apikey",
"unauthorized",
"authentication",
"invalid authentication",
];
return authHints.some(
(hint) =>
loweredMessage.includes(hint) || loweredCode.includes(hint) || loweredType.includes(hint)
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,75 @@
import { ABORT_REASON } from "@/constants";
import { ABORT_REASON, ModelCapability } from "@/constants";
import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter";
import { logInfo } from "@/logger";
import { getSystemPrompt } from "@/settings/model";
import { getSettings } from "@/settings/model";
import { ChatMessage } from "@/types/message";
import { extractChatHistory, getMessageRole, withSuppressedTokenWarnings } from "@/utils";
import { findCustomModel, withSuppressedTokenWarnings } from "@/utils";
import { BaseChainRunner } from "./BaseChainRunner";
import { loadAndAddChatHistory } from "./utils/chatHistoryUtils";
import { recordPromptPayload } from "./utils/promptPayloadRecorder";
import { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
import { getModelKey } from "@/aiParams";
export class LLMChainRunner extends BaseChainRunner {
/**
* Construct messages array using envelope-based context (L1-L5 layers)
* Requires context envelope - throws error if unavailable
*/
private async constructMessages(
userMessage: ChatMessage
): Promise<{ role: string; content: string | unknown[] }[]> {
// Require envelope for LLM chain
if (!userMessage.contextEnvelope) {
throw new Error(
"[LLMChainRunner] Context envelope is required but not available. Cannot proceed with LLM chain."
);
}
logInfo("[LLMChainRunner] Using envelope-based context");
// Convert envelope to messages (L1 system + L2+L3+L5 user)
const baseMessages = LayerToMessagesConverter.convert(userMessage.contextEnvelope, {
includeSystemMessage: true,
mergeUserContent: true,
debug: false,
});
const messages: { role: string; content: string | unknown[] }[] = [];
// Add system message (L1)
const systemMessage = baseMessages.find((m) => m.role === "system");
if (systemMessage) {
messages.push(systemMessage);
}
// Add chat history (L4)
const memory = this.chainManager.memoryManager.getMemory();
await loadAndAddChatHistory(memory, messages);
// Add user message (L2+L3+L5 merged)
const userMessageContent = baseMessages.find((m) => m.role === "user");
if (userMessageContent) {
// Handle multimodal content if present
if (userMessage.content && Array.isArray(userMessage.content)) {
// Merge envelope text with multimodal content (images)
const updatedContent = userMessage.content.map((item: { type?: string }) => {
if (item.type === "text") {
return { ...item, text: userMessageContent.content };
}
return item;
});
messages.push({
role: "user",
content: updatedContent,
});
} else {
messages.push(userMessageContent);
}
}
return messages;
}
async run(
userMessage: ChatMessage,
abortController: AbortController,
@ -18,61 +81,49 @@ export class LLMChainRunner extends BaseChainRunner {
updateLoading?: (loading: boolean) => void;
}
): Promise<string> {
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage);
// Check if the current model has reasoning capability
const settings = getSettings();
const modelKey = getModelKey();
let excludeThinking = false;
try {
// Get chat history from memory
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
const currentModel = findCustomModel(modelKey, settings.activeModels);
// Exclude thinking blocks if model doesn't have REASONING capability
excludeThinking = !currentModel.capabilities?.includes(ModelCapability.REASONING);
} catch (error) {
// If we can't find the model, default to including thinking blocks
logInfo(
"Could not determine model capabilities, defaulting to include thinking blocks",
error
);
}
// Create messages array starting with system message
const messages: any[] = [];
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking);
// Add system message if available
const systemPrompt = getSystemPrompt();
try {
// Construct messages using envelope or legacy approach
const messages = await this.constructMessages(userMessage);
// Record the payload for debugging (includes layered view if envelope available)
const chatModel = this.chainManager.chatModelManager.getChatModel();
if (systemPrompt) {
messages.push({
role: getMessageRole(chatModel),
content: systemPrompt,
});
}
// Add chat history
for (const entry of chatHistory) {
messages.push({ role: entry.role, content: entry.content });
}
// Add current user message - support multimodal content if available
if (userMessage.content && Array.isArray(userMessage.content)) {
// For multimodal messages with images, replace the text content with processed text
const updatedContent = userMessage.content.map((item: any) => {
if (item.type === "text") {
// Use processed message text that includes context
return { ...item, text: userMessage.message };
}
return item;
});
messages.push({
role: "user",
content: updatedContent,
});
} else {
messages.push({
role: "user",
content: userMessage.message,
});
}
const modelName = (chatModel as { modelName?: string } | undefined)?.modelName;
recordPromptPayload({
messages,
modelName,
contextEnvelope: userMessage.contextEnvelope,
});
logInfo("Final Request to AI:\n", messages);
// Stream with abort signal
const chatStream = await withSuppressedTokenWarnings(() =>
this.chainManager.chatModelManager.getChatModel().stream(messages, {
signal: abortController.signal,
})
this.chainManager.chatModelManager.getChatModel().stream(
// ProviderMessage[] format matches what getChatModel().stream() accepts at runtime
messages as never,
{
signal: abortController.signal,
}
)
);
for await (const chunk of chatStream) {
@ -80,20 +131,29 @@ export class LLMChainRunner extends BaseChainRunner {
logInfo("Stream iteration aborted", { reason: abortController.signal.reason });
break;
}
streamer.processChunk(chunk);
streamer.processChunk(chunk as Parameters<typeof streamer.processChunk>[0]);
}
} catch (error: any) {
} catch (error: unknown) {
// Check if the error is due to abort signal
if (error.name === "AbortError" || abortController.signal.aborted) {
const errorName = error instanceof Error ? error.name : "";
if (errorName === "AbortError" || abortController.signal.aborted) {
logInfo("Stream aborted by user", { reason: abortController.signal.reason });
// Don't show error message for user-initiated aborts
} else {
await this.handleError(error, addMessage, updateCurrentAiMessage);
await this.handleError(
error,
streamer.processErrorChunk.bind(streamer) as (message: string) => void
);
}
}
// Always return the response, even if partial
const response = streamer.close();
const result = streamer.close();
const responseMetadata = {
wasTruncated: result.wasTruncated,
tokenUsage: result.tokenUsage ?? undefined,
};
// Only skip saving if it's a new chat (clearing everything)
if (abortController.signal.aborted && abortController.signal.reason === ABORT_REASON.NEW_CHAT) {
@ -101,12 +161,17 @@ export class LLMChainRunner extends BaseChainRunner {
return "";
}
return this.handleResponse(
response,
await this.handleResponse(
result.content,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
updateCurrentAiMessage,
undefined,
undefined,
responseMetadata
);
return result.content;
}
}

View file

@ -1,25 +1,11 @@
import { getCurrentProject } from "@/aiParams";
import { getSystemPrompt } from "@/settings/model";
import ProjectManager from "../projectManager";
import { CopilotPlusChainRunner } from "./CopilotPlusChainRunner";
/**
* ProjectChainRunner - Chain runner for project-based chats
*
* Project context is automatically added to L1 via ChatManager.getSystemPromptForMessage()
* No override needed - inherits all behavior from CopilotPlusChainRunner
*/
export class ProjectChainRunner extends CopilotPlusChainRunner {
protected async getSystemPrompt(): Promise<string> {
let finalPrompt = getSystemPrompt();
const projectConfig = getCurrentProject();
if (!projectConfig) {
return finalPrompt;
}
// Get context asynchronously
const context = await ProjectManager.instance.getProjectContext(projectConfig.id);
finalPrompt = `${finalPrompt}\n\n<project_system_prompt>\n${projectConfig.systemPrompt}\n</project_system_prompt>`;
// TODO: Move project context out of the system prompt and into the user prompt.
if (context) {
finalPrompt = `${finalPrompt}\n\n <project_context>\n${context}\n</project_context>`;
}
return finalPrompt;
}
// No overrides needed - project context automatically in L1 via ChatManager
}

View file

@ -6,8 +6,8 @@ This directory contains the refactored chain runner system for Obsidian Copilot,
The chain runner system provides two distinct tool calling approaches:
1. **Legacy Tool Calling** (CopilotPlusChainRunner) - Uses Brevilabs API for intent analysis
2. **Autonomous Agent** (AutonomousAgentChainRunner) - Uses XML-based tool calling
1. **Copilot Plus** (CopilotPlusChainRunner) - Uses native tool calling for intent analysis
2. **Autonomous Agent** (AutonomousAgentChainRunner) - Uses native LangChain tool calling with ReAct pattern
## Architecture
@ -18,46 +18,47 @@ chainRunner/
├── VaultQAChainRunner.ts # Vault-only Q&A with retrieval
├── CopilotPlusChainRunner.ts # Legacy tool calling system
├── ProjectChainRunner.ts # Project-aware extension of Plus
├── AutonomousAgentChainRunner.ts # XML-based autonomous agent tool calling
├── AutonomousAgentChainRunner.ts # Native tool calling with ReAct agent loop
├── index.ts # Main exports
└── utils/
├── ThinkBlockStreamer.ts # Handles thinking content from models
├── xmlParsing.ts # XML tool call parsing utilities
├── xmlParsing.ts # XML escape/unescape utilities (for context envelope)
├── toolExecution.ts # Tool execution helpers
└── modelAdapter.ts # Model-specific adaptations
```
## Tool Calling Systems Comparison
### 1. Legacy Tool Calling (CopilotPlusChainRunner)
### 1. Model-Based Tool Planning (CopilotPlusChainRunner)
**How it works:**
- Uses Brevilabs API (`IntentAnalyzer.analyzeIntent()`) to analyze user intent
- Determines which tools to call based on the analysis
- Executes tools synchronously before sending to LLM
- Uses chat model with `bindTools()` to plan which tools to call
- Model outputs tool calls via native `tool_calls` property on AIMessage
- Executes tools synchronously before sending to LLM for final response
- Enhances user message with tool outputs as context
- Supports `@` commands for explicit tool invocation (`@vault`, `@websearch`, `@memory`)
**Flow:**
```
User Message → Intent Analysis → Tool Execution → Enhanced Prompt → LLM Response
User Message → Model Planning → Tool Execution → Enhanced Prompt → LLM Response
```
**Example:**
```typescript
// 1. Analyze intent
const toolCalls = await IntentAnalyzer.analyzeIntent(message);
// 1. Plan tools using model
const { toolCalls, salientTerms } = await this.planToolCalls(message, chatModel);
// 2. Execute tools
// 2. Process @commands (add localSearch, webSearch, etc. if needed)
toolCalls = await this.processAtCommands(message, toolCalls, { salientTerms });
// 3. Execute tools
const toolOutputs = await this.executeToolCalls(toolCalls);
// 3. Enhance message with context
const enhancedMessage = this.prepareEnhancedUserMessage(message, toolOutputs);
// 4. Send to LLM
const response = await this.streamMultimodalResponse(enhancedMessage, ...);
const response = await this.streamMultimodalResponse(message, toolOutputs, ...);
```
**Tools Available:**
@ -73,102 +74,218 @@ const response = await this.streamMultimodalResponse(enhancedMessage, ...);
**How it works:**
- **No LangChain dependency** - Uses simple tool interface with XML-based tool calling
- AI decides autonomously which tools to use via structured XML format
- Uses native LangChain tool calling via `bindTools()` with ReAct pattern
- AI decides autonomously which tools to use via structured `tool_calls`
- Iterative loop where AI can call multiple tools in sequence
- Each tool result informs the next decision
- Each tool result informs the next decision via `ToolMessage`
**Flow:**
```
User Message → AI Reasoning → XML Tool Call → Tool Execution →
AI Analysis → More Tools? → Final Response
User Message → AI Reasoning → tool_calls → Tool Execution →
ToolMessage → AI Analysis → More Tools? → Final Response
```
**XML Tool Call Format:**
```xml
<use_tool>
<name>localSearch</name>
<args>
{
"query": "machine learning notes",
"salientTerms": ["machine", "learning", "AI", "algorithms"]
}
</args>
</use_tool>
```
**Sequential Loop:**
**Native Tool Call Format:**
```typescript
// AIMessage.tool_calls contains structured tool calls
const toolCalls = response.tool_calls; // Array of { name, args, id }
// Example tool call:
{
name: "localSearch",
args: {
query: "machine learning notes",
salientTerms: ["machine", "learning", "AI", "algorithms"]
},
id: "call_abc123"
}
```
**ReAct Loop:**
```typescript
// Bind tools to model for native tool calling
const boundModel = chatModel.bindTools(availableTools);
while (iteration < maxIterations) {
// 1. Get AI response
const response = await this.streamResponse(messages);
// 1. Get AI response with potential tool calls
const response = await boundModel.invoke(messages);
messages.push(response);
// 2. Parse XML tool calls
const toolCalls = parseXMLToolCalls(response);
if (toolCalls.length === 0) {
// 2. Check for tool calls in structured format
if (!response.tool_calls || response.tool_calls.length === 0) {
// No tools needed - final response
break;
}
// 3. Execute each tool
for (const toolCall of toolCalls) {
// 3. Execute each tool and add ToolMessage
for (const toolCall of response.tool_calls) {
const result = await executeSequentialToolCall(toolCall, availableTools);
toolResults.push(result);
messages.push(
new ToolMessage({
content: JSON.stringify(result),
tool_call_id: toolCall.id,
name: toolCall.name,
})
);
}
// 4. Add results to conversation for next iteration
messages.push({ role: "user", content: toolResultsForConversation });
// 4. Continue loop - AI sees tool results via ToolMessage
}
```
### ReAct Prompting Flow
Each iteration sends the following message structure to the LLM:
**Iteration 1 (Initial):**
```
messages = [
SystemMessage: "You are a helpful assistant... [tool descriptions via bindTools]"
HumanMessage: "What did I write about machine learning last week?"
]
```
**Iteration 1 Response:**
```
AIMessage: {
content: "", // May be empty or contain reasoning
tool_calls: [{
id: "call_abc123",
name: "getTimeRangeMs",
args: { description: "last week" }
}]
}
```
**Iteration 2 (After Tool Execution):**
```
messages = [
SystemMessage: "..."
HumanMessage: "What did I write about machine learning last week?"
AIMessage: { tool_calls: [getTimeRangeMs] }
ToolMessage: { tool_call_id: "call_abc123", content: '{"startTime":1736..., "endTime":1737...}' }
]
```
**Iteration 2 Response:**
```
AIMessage: {
content: "",
tool_calls: [{
id: "call_def456",
name: "localSearch",
args: {
query: "machine learning",
salientTerms: ["machine learning", "ML", "AI"],
timeRange: { startTime: 1736..., endTime: 1737... }
}
}]
}
```
**Iteration 3 (After Second Tool):**
```
messages = [
SystemMessage: "..."
HumanMessage: "What did I write about machine learning last week?"
AIMessage: { tool_calls: [getTimeRangeMs] }
ToolMessage: { tool_call_id: "call_abc123", content: '{"startTime":..., "endTime":...}' }
AIMessage: { tool_calls: [localSearch] }
ToolMessage: { tool_call_id: "call_def456", content: '{"documents": [...5 results...]}' }
]
```
**Final Response (No tool_calls):**
```
AIMessage: {
content: "Based on your notes from last week, you wrote about...",
tool_calls: [] // Empty = final response, exit loop
}
```
### Key Points
1. **Tool schemas** are provided via `bindTools()` - the LLM sees them in its context
2. **AIMessage with tool_calls** triggers tool execution; **AIMessage without tool_calls** is final response
3. **ToolMessage** correlates with AIMessage via `tool_call_id`
4. **Conversation history grows** - each iteration sees all previous messages
5. **Max 4 iterations** to prevent infinite loops
## Key Differences
| Aspect | Legacy (Plus) | Autonomous Agent |
| ------------------ | ----------------------- | ------------------------------------- |
| **Tool Decision** | Brevilabs API analysis | AI decides autonomously |
| **Tool Execution** | Pre-LLM, synchronous | During conversation, iterative |
| **Tool Format** | SimpleTool interface | XML-based structured format |
| **Reasoning** | Intent analysis → tools | AI reasoning → tools → more reasoning |
| **Iterations** | Single pass | Up to 4 iterations |
| **Tool Chaining** | Limited | Full chaining support |
| Aspect | Copilot Plus | Autonomous Agent |
| ------------------ | ------------------------------- | ------------------------------------- |
| **Tool Decision** | Model-based intent planning | AI decides autonomously (ReAct) |
| **Tool Execution** | Pre-LLM, synchronous | During conversation, iterative |
| **Tool Format** | Native tool calling (bindTools) | Native tool calling (bindTools) |
| **Reasoning** | Intent analysis → tools | AI reasoning → tools → more reasoning |
| **Iterations** | Single pass | Up to 4 iterations |
| **Tool Chaining** | Limited | Full chaining support |
## SimpleTool Interface
## LangChain Tool Interface
### Overview
The SimpleTool interface provides a clean, type-safe way to define tools with Zod validation:
Tools are created using native LangChain's `tool()` function via the `createLangChainTool` helper, with Zod schema validation. Tool metadata (execution control, display info) is stored separately in `ToolRegistry`.
```typescript
interface SimpleTool<TSchema extends z.ZodType = z.ZodVoid> {
// Tool creation returns a LangChain StructuredTool
const myTool = createLangChainTool({
name: string;
description: string;
schema: TSchema;
call: (args: z.infer<TSchema>) => Promise<any>;
schema: z.ZodType;
func: (args) => Promise<string | object>;
});
// Tool metadata stored in ToolRegistry
interface ToolMetadata {
id: string;
displayName: string;
description: string;
category: "search" | "time" | "file" | "media" | "mcp" | "memory" | "custom";
isAlwaysEnabled?: boolean;
timeoutMs?: number;
isBackground?: boolean;
isPlusOnly?: boolean;
}
```
### Creating Tools
All tools are created using the unified `createTool` function with Zod schemas:
All tools are created using `createLangChainTool` with Zod schemas:
#### Tool with No Parameters
```typescript
const indexTool = createTool({
const indexTool = createLangChainTool({
name: "indexVault",
description: "Index the vault to the Copilot index",
schema: z.void(), // No parameters
handler: async () => {
schema: z.object({}), // Empty object for no parameters
func: async () => {
// Tool implementation
return "Indexing complete";
return { status: "complete" };
},
});
// Register with metadata
registry.register({
tool: indexTool,
metadata: {
id: "indexVault",
displayName: "Index Vault",
description: "Index the vault",
category: "file",
isBackground: true,
},
isBackground: true, // Optional: hide from user
});
```
@ -189,27 +306,36 @@ const searchSchema = z.object({
});
// Create tool with automatic validation
const searchTool = createTool({
const searchTool = createLangChainTool({
name: "localSearch",
description: "Search for notes based on query and time range",
schema: searchSchema,
handler: async ({ query, salientTerms, timeRange }) => {
func: async ({ query, salientTerms, timeRange }) => {
// Handler receives fully typed and validated arguments
// TypeScript knows the exact types from the schema
return performSearch(query, salientTerms, timeRange);
},
timeoutMs: 30000, // Optional: custom timeout
});
// Register with metadata
registry.register({
tool: searchTool,
metadata: {
id: "localSearch",
displayName: "Vault Search",
category: "search",
timeoutMs: 30000,
},
});
```
### Benefits of Unified Zod Approach
### Benefits of LangChain Native Tools
1. **Type Safety**: Full TypeScript type inference from schemas
1. **Type Safety**: Full TypeScript type inference from Zod schemas
2. **Runtime Validation**: All inputs validated before reaching handler
3. **Consistent Interface**: One way to create all tools
3. **Native LangChain Integration**: Compatible with `bindTools()` and LangChain tooling ecosystem
4. **Better Error Messages**: Zod provides detailed validation errors
5. **No Any Types**: Everything is properly typed
6. **Simpler Codebase**: No need to maintain multiple tool creation methods
5. **Separation of Concerns**: Tool implementation separate from execution metadata
6. **Future-Proof**: Ready for native tool calling when models support it
### Advanced Zod Patterns
@ -243,11 +369,11 @@ const actionSchema = z.discriminatedUnion("type", [
}),
]);
const actionTool = createTool({
const actionTool = createLangChainTool({
name: "performAction",
description: "Perform various actions",
schema: actionSchema,
handler: async (action) => {
func: async (action) => {
// TypeScript knows exactly which type based on discriminator
switch (action.type) {
case "search":
@ -322,10 +448,10 @@ const configSchema = z.object({
});
// Handler receives object with defaults applied
const configTool = createTool({
const configTool = createLangChainTool({
name: "updateConfig",
schema: configSchema,
handler: async (config) => {
func: async (config) => {
// config.temperature is always defined (0.7 if not provided)
// config.maxTokens is always defined (1000 if not provided)
// config.model is always defined ("gpt-4" if not provided)
@ -340,7 +466,7 @@ When AI-generated parameters fail Zod validation, the tool execution will return
```typescript
// Example tool with strict validation
const searchToolWithValidation = createTool({
const searchToolWithValidation = createLangChainTool({
name: "searchNotes",
description: "Search notes with specific criteria",
schema: z.object({
@ -348,7 +474,7 @@ const searchToolWithValidation = createTool({
limit: z.number().int().min(1).max(100),
sortBy: z.enum(["relevance", "date", "title"]),
}),
handler: async ({ query, limit, sortBy }) => {
func: async ({ query, limit, sortBy }) => {
return performSearch(query, limit, sortBy);
},
});
@ -371,22 +497,7 @@ const searchToolWithValidation = createTool({
The validation errors are automatically formatted to be clear and actionable, helping the AI self-correct. The autonomous agent's iterative design naturally provides retry capability with the AI learning from each error.
## XML Tool Calling Details
### Tool Call Parsing (`xmlParsing.ts`)
```typescript
// Parse XML tool calls from AI response
function parseXMLToolCalls(text: string): ToolCall[] {
const regex = /<use_tool>([\s\S]*?)<\/use_tool>/g;
// Extracts name and args from XML structure
}
// Strip tool calls from display
function stripToolCallXML(text: string): string {
// Removes XML tool blocks and code blocks for clean display
}
```
## Native Tool Calling Details
### Tool Execution (`toolExecution.ts`)
@ -400,16 +511,23 @@ async function executeSequentialToolCall(
// Error handling and validation
// Result formatting
}
// ToolCall interface (from native tool calling)
interface ToolCall {
name: string;
args: Record<string, unknown>;
id?: string; // Used for ToolMessage correlation
}
```
### Available Tools in Sequential Mode
### Available Tools in Agent Mode
All tools from the legacy system plus autonomous decision-making:
All tools from the Copilot Plus system plus autonomous decision-making:
- **localSearch** - Vault content search with salient terms
- **localSearch** - Vault content search with salient terms and query expansion
- **webSearch** - Web search with chat history context
- **getFileTree** - File structure exploration
- **getCurrentTime** - Time-based queries
- **getCurrentTime** / **getTimeRangeMs** - Time-based queries
- **pomodoroTool** - Productivity timer
- **indexTool** - Vault indexing operations
- **youtubeTranscription** - Video content analysis
@ -418,26 +536,22 @@ All tools from the legacy system plus autonomous decision-making:
The Autonomous Agent mode uses a comprehensive system prompt that:
1. **Explains the XML format** with exact examples
2. **Provides tool descriptions** with parameter details
1. **Describes available tools** - Tool schemas are provided via `bindTools()`
2. **Provides behavioral guidance** - When to use each tool, how to chain them
3. **Sets expectations** for reasoning and tool chaining
4. **Includes critical requirements** (e.g., salientTerms for localSearch)
Example system prompt section:
Tool descriptions are provided automatically via Zod schemas. Model adapters add behavioral guidance:
```
When you need to use a tool, format it EXACTLY like this:
<use_tool>
<name>localSearch</name>
<args>
{
"query": "piano learning",
"salientTerms": ["piano", "learning", "practice", "music"]
```typescript
// Example: Model adapter adds tool usage guidance
enhanceSystemPrompt(basePrompt: string): string {
return basePrompt + `
When searching notes, always provide both "query" (string) and "salientTerms" (array of key terms).
Use getTimeRangeMs before localSearch for time-based queries.
`;
}
</args>
</use_tool>
CRITICAL: For localSearch, you MUST always provide both "query" (string) and "salientTerms" (array of strings).
```
## Benefits of Autonomous Agent
@ -445,9 +559,10 @@ CRITICAL: For localSearch, you MUST always provide both "query" (string) and "sa
1. **Autonomous Tool Selection** - AI decides what tools to use without pre-analysis
2. **Tool Chaining** - Can use results from one tool to inform the next
3. **Complex Workflows** - Multi-step reasoning with tool support
4. **Model Agnostic** - Works with any LLM that can follow XML format
4. **Model Agnostic** - Works with any LLM that supports native tool calling
5. **No External Dependencies** - No Brevilabs API required
6. **Transparency** - User can see the AI's reasoning process
6. **Transparency** - User can see the AI's reasoning process via Agent Reasoning Block
7. **Native Integration** - Uses LangChain's `bindTools()` for proper tool calling support
## Usage
@ -510,17 +625,12 @@ The Model Adapter pattern handles model-specific quirks and requirements cleanly
interface ModelAdapter {
enhanceSystemPrompt(basePrompt: string, toolDescriptions: string): string;
enhanceUserMessage(message: string, requiresTools: boolean): string;
parseToolCalls?(response: string): any[]; // Future extension
needsSpecialHandling(): boolean;
sanitizeResponse?(response: string, iteration: number): string;
shouldTruncateStreaming?(partialResponse: string): boolean;
detectPrematureResponse?(response: string): {
hasPremature: boolean;
type: "before" | "after" | null;
};
}
```
> **Note:** With native tool calling via `bindTools()`, tool calls are returned in structured `response.tool_calls` format. Model adapters now focus on behavioral guidance rather than parsing or response sanitization.
### Current Adapters
1. **BaseModelAdapter** - Default behavior for well-behaved models
@ -559,58 +669,17 @@ The `ClaudeModelAdapter` includes specialized handling for Claude thinking model
- **Think Block Preservation** - Maintains valuable reasoning context in responses
- **Temperature Control** - Disables temperature for thinking models (as required by API)
#### Claude 4 Hallucination Prevention
> **Note:** With native tool calling, tool calls are returned in structured `response.tool_calls` format. Intermediate tool calls are hidden from users and displayed via the Agent Reasoning Block. Only final responses are streamed to the UI.
Claude 4 has a tendency to write complete responses immediately after tool calls instead of waiting for results. The adapter addresses this with:
#### Agent Reasoning Block
```typescript
// Enhanced prompting with explicit autonomous agent pattern
enhanceSystemPrompt(basePrompt: string, toolDescriptions: string): string {
if (this.isClaudeSonnet4()) {
// Add specific instructions for Claude 4:
// - Brief sentence + tool calls + STOP pattern
// - Explicit warnings about premature responses
// - Clear autonomous agent iteration guidance
}
}
// Detection of premature responses
detectPrematureResponse(response: string): {
hasPremature: boolean;
type: "before" | "after" | null;
} {
// Allows brief sentences before tool calls (up to 2 sentences, 200 chars)
// Detects substantial content after tool calls (forbidden)
// Uses threshold-based detection for generalizability
}
// Response sanitization
sanitizeResponse(response: string, iteration: number): string {
// Preserves ALL think blocks
// Removes substantial non-thinking content after tool calls
// Only applies to first iteration when hallucination occurs
}
// Streaming truncation
shouldTruncateStreaming(partialResponse: string): boolean {
// Prevents streaming of hallucinated content to users
// Truncates at last complete tool call when threshold exceeded
}
```
#### Flow Improvement
The adapter creates a better conversational flow by allowing brief explanatory sentences before tool calls:
The reasoning process is now displayed via the Agent Reasoning Block component, which shows:
```
[Think block]
I'll search your vault and web for piano practice information.
🔍 Calling vault search...
[Think block]
Let me gather more specific information about practice routines.
🌐 Calling web search...
[Think block]
[final answer]
⏱️ 2.3s elapsed
├─ Searching notes for "piano", "learning", "practice"...
├─ Found 5 notes: Piano Practice.md, Learning Music.md...
└─ Generating response...
```
### Benefits
@ -631,10 +700,9 @@ Let me gather more specific information about practice routines.
3. **Parallel Execution** - Multiple tools simultaneously
4. **Tool Result Caching** - Avoid redundant calls
5. **Advanced Reasoning** - More sophisticated decision trees
6. **Tool Permissions** - User control over tool access
7. **Alternative Parsing** - Model adapters could handle non-XML formats
6. **Tool Permissions** - User control over tool access (human-in-the-loop approval)
7. **Deep Search** - Iterative search refinement for complex queries
8. **Response Validation** - Adapters could validate model outputs
9. **Model-Specific Optimizations** - Expand adapter capabilities for emerging models
10. **Hallucination Detection** - More sophisticated premature response detection
The autonomous agent approach represents a significant evolution from traditional tool calling, enabling more sophisticated AI reasoning and autonomous task completion.
The autonomous agent approach using native tool calling represents a significant evolution from traditional tool calling, enabling more sophisticated AI reasoning and autonomous task completion.

View file

@ -1,24 +1,33 @@
import { ABORT_REASON, RETRIEVED_DOCUMENT_TAG } from "@/constants";
import { ABORT_REASON, ModelCapability, RETRIEVED_DOCUMENT_TAG } from "@/constants";
import { getStandaloneQuestion } from "@/chainUtils";
import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter";
import { logInfo } from "@/logger";
import { HybridRetriever } from "@/search/hybridRetriever";
import { TieredLexicalRetriever } from "@/search/v3/TieredLexicalRetriever";
import { getSettings, getSystemPrompt } from "@/settings/model";
import { RetrieverFactory } from "@/search/RetrieverFactory";
import { FilterRetriever } from "@/search/v3/FilterRetriever";
import { mergeFilterAndSearchResults } from "@/search/v3/mergeResults";
import { extractTagsFromQuery } from "@/search/v3/utils/tagUtils";
import { getSettings } from "@/settings/model";
import { ChatMessage } from "@/types/message";
import {
extractChatHistory,
extractUniqueTitlesFromDocs,
findCustomModel,
getMessageRole,
withSuppressedTokenWarnings,
} from "@/utils";
import { BaseChainRunner } from "./BaseChainRunner";
import { loadAndAddChatHistory } from "./utils/chatHistoryUtils";
import {
formatSourceCatalog,
getQACitationInstructionsConditional,
getQACitationInstructions,
sanitizeContentForCitations,
addFallbackSources,
hasInlineCitations,
type SourceCatalogEntry,
} from "./utils/citationUtils";
import { recordPromptPayload } from "./utils/promptPayloadRecorder";
import { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
import { getModelKey } from "@/aiParams";
export class VaultQAChainRunner extends BaseChainRunner {
async run(
@ -32,45 +41,96 @@ export class VaultQAChainRunner extends BaseChainRunner {
updateLoading?: (loading: boolean) => void;
}
): Promise<string> {
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage);
// Check if the current model has reasoning capability
const settings = getSettings();
const modelKey = getModelKey();
let excludeThinking = false;
try {
const currentModel = findCustomModel(modelKey, settings.activeModels);
// Exclude thinking blocks if model doesn't have REASONING capability
excludeThinking = !currentModel.capabilities?.includes(ModelCapability.REASONING);
} catch (error) {
// If we can't find the model, default to including thinking blocks
logInfo(
"Could not determine model capabilities, defaulting to include thinking blocks",
error
);
}
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking);
try {
// Tiered lexical retriever doesn't need index check - it builds indexes on demand
// Get chat history from memory
// Require envelope for VaultQA
const envelope = userMessage.contextEnvelope;
if (!envelope) {
throw new Error(
"[VaultQA] Context envelope is required but not available. Cannot proceed with VaultQA chain."
);
}
// Step 1: Extract L5 (raw user query) from envelope
// Tags MUST be extracted from L5 BEFORE condensing to preserve them for tag-aware retrieval
const l5User = envelope.layers.find((l) => l.id === "L5_USER");
const rawUserQuery = l5User?.text || userMessage.message;
// Step 2: Extract tags from raw query (BEFORE condensing!)
const tags = this.extractTagTerms(rawUserQuery);
logInfo("[VaultQA] Extracted tags before condensing:", tags);
// Step 3: Get chat history from memory (L4)
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
// Generate standalone question from user message + chat history
// This is similar to what the conversational retrieval chain does
let standaloneQuestion = userMessage.message;
// Step 4: Condense L4 + L5 into standalone question for RAG retrieval
// This improves retrieval by incorporating conversation context
let standaloneQuestion = rawUserQuery;
if (chatHistory.length > 0) {
// For simplicity, we'll use the original question directly
// The original chain would rephrase it, but this approach should work for most cases
standaloneQuestion = userMessage.message;
logInfo("[VaultQA] Condensing query with chat history for better retrieval");
standaloneQuestion = await getStandaloneQuestion(rawUserQuery, chatHistory);
logInfo("[VaultQA] Standalone question:", standaloneQuestion);
}
// Create retriever based on semantic search setting
// Step 5: Create retriever based on semantic search setting
const settings = getSettings();
const retriever = settings.enableSemanticSearchV3
? new HybridRetriever({
minSimilarityScore: 0.01,
maxK: settings.maxSourceChunks,
salientTerms: [],
})
: new TieredLexicalRetriever(app, {
minSimilarityScore: 0.01,
maxK: settings.maxSourceChunks,
salientTerms: [],
timeRange: undefined,
textWeight: undefined,
returnAll: false,
useRerankerThreshold: undefined,
});
// Retrieve relevant documents
const retrievedDocs = await retriever.getRelevantDocuments(standaloneQuestion);
// Step 5a: Run FilterRetriever for guaranteed title/tag matches
const hasTagTerms = tags.length > 0;
const filterRetriever = new FilterRetriever(app, {
salientTerms: hasTagTerms ? [...tags] : [],
maxK: settings.maxSourceChunks,
returnAll: hasTagTerms,
});
const filterDocs = await filterRetriever.getRelevantDocuments(standaloneQuestion);
// Step 5b: Create main retriever using factory (handles priority: Self-hosted > Semantic > Lexical)
// Miyo is only relevant to Plus/agent chains — bypass it for VaultQA.
// When Miyo is active, Orama isn't initialized either, so also skip semantic → use lexical.
const miyoActive = RetrieverFactory.isMiyoActive();
const retrieverResult = await RetrieverFactory.createRetriever(
app,
{
minSimilarityScore: 0.01,
maxK: settings.maxSourceChunks,
salientTerms: hasTagTerms ? [...tags] : [],
tagTerms: tags,
returnAll: hasTagTerms,
},
miyoActive ? { enableMiyo: false, enableSemanticSearchV3: false } : {}
);
const retriever = retrieverResult.retriever;
logInfo(`VaultQA: Using ${retrieverResult.type} retriever - ${retrieverResult.reason}`);
// Step 5c: Retrieve search results and merge with filter results
const searchDocs = await retriever.getRelevantDocuments(standaloneQuestion);
const { filterResults, searchResults } = mergeFilterAndSearchResults(filterDocs, searchDocs);
// Cap total docs to prevent oversized prompts (filter results prioritized)
const merged = [...filterResults, ...searchResults];
const retrieverCapReached = merged.length > settings.maxSourceChunks;
const retrievedDocs = merged.slice(0, settings.maxSourceChunks);
// Store retrieved documents for sources
this.chainManager.storeRetrieverDocuments(retrievedDocs);
@ -78,77 +138,115 @@ export class VaultQAChainRunner extends BaseChainRunner {
// Format documents as context with XML tags
// Sanitize content to remove pre-existing citation markers
const context = retrievedDocs
.map((doc: any) => {
const context = (
retrievedDocs as { metadata?: { title?: string; path?: string }; pageContent?: string }[]
)
.map((doc) => {
const title = doc.metadata?.title || "Untitled";
const path = doc.metadata?.path || title;
return `<${RETRIEVED_DOCUMENT_TAG}>\n<title>${title}</title>\n<path>${path}</path>\n<content>\n${sanitizeContentForCitations(doc.pageContent)}\n</content>\n</${RETRIEVED_DOCUMENT_TAG}>`;
return `<${RETRIEVED_DOCUMENT_TAG}>\n<title>${title}</title>\n<path>${path}</path>\n<content>\n${sanitizeContentForCitations(doc.pageContent ?? "")}\n</content>\n</${RETRIEVED_DOCUMENT_TAG}>`;
})
.join("\n\n");
// Create messages array
const messages: any[] = [];
// Step 6: Build messages array with envelope-aware logic
const messages: { role: string; content: string | unknown[] }[] = [];
const chatModel = this.chainManager.chatModelManager.getChatModel();
// Add system message with QA instruction
const systemPrompt = getSystemPrompt();
// Prepare citation mapping so the model can produce inline [#] and a Sources footer
// Build an unnumbered source catalog to avoid biasing the model with arbitrary numbers
// The model is instructed to assign [1..N] as it cites sources in the answer.
const sourceEntries: SourceCatalogEntry[] = retrievedDocs
// Prepare RAG context and citation instructions
const sourceEntries: SourceCatalogEntry[] = (
retrievedDocs as { metadata?: { title?: string; path?: string } }[]
)
.slice(0, Math.max(5, Math.min(20, retrievedDocs.length)))
.map((d: any) => ({
.map((d) => ({
title: d.metadata?.title || d.metadata?.path || "Untitled",
path: d.metadata?.path || d.metadata?.title || "",
}));
const sourceCatalog = formatSourceCatalog(sourceEntries).join("\n");
const capNotice = retrieverCapReached
? `\n\nIMPORTANT: The retrieval limit of ${settings.maxSourceChunks} documents was reached. ${merged.length - settings.maxSourceChunks} additional matching documents were omitted. Inform the user: "Note: The retrieval cap was reached — some matching documents were not included. Upgrade to Copilot Plus for more complete answers."`
: "";
const qaInstructions =
"\n\nAnswer the question based only on the following context:\n" +
context +
getQACitationInstructionsConditional(settings.enableInlineCitations, sourceCatalog);
const fullSystemMessage = systemPrompt + qaInstructions;
getQACitationInstructions(sourceCatalog, settings.enableInlineCitations) +
capNotice;
const chatModel = this.chainManager.chatModelManager.getChatModel();
if (fullSystemMessage) {
// Build messages using envelope-based context construction
logInfo("[VaultQA] Using envelope-based context construction with LayerToMessagesConverter");
// Use LayerToMessagesConverter to get base messages with L1+L2 system, L3+L5 user
// This ensures smart referencing and L2 Context Library are preserved
const baseMessages = LayerToMessagesConverter.convert(envelope, {
includeSystemMessage: true,
mergeUserContent: true,
debug: false,
});
// Add system message (L1 + L2 Context Library only - no RAG)
const systemMessage = baseMessages.find((m) => m.role === "system");
if (systemMessage) {
messages.push({
role: getMessageRole(chatModel),
content: fullSystemMessage,
content: systemMessage.content,
});
}
// Add chat history
for (const entry of chatHistory) {
messages.push({ role: entry.role, content: entry.content });
// Insert L4 (chat history) between system and user
await loadAndAddChatHistory(memory, messages);
// Add user message with RAG prepended
// User message now contains: RAG results + citations + L3 smart references + L5
// LayerToMessagesConverter already handles smart referencing:
// - Items in L2 → referenced by ID
// - Items NOT in L2 → full content
const userMessageContent = baseMessages.find((m) => m.role === "user");
if (userMessageContent) {
// Prepend RAG results and citations to user content with proper separator
const enhancedUserContent = qaInstructions + "\n\n" + userMessageContent.content;
// Handle multimodal content if present
if (userMessage.content && Array.isArray(userMessage.content)) {
const updatedContent = userMessage.content.map(
(item: { type?: string }): { type?: string; [key: string]: unknown } => {
if (item.type === "text") {
return { ...item, text: enhancedUserContent };
}
return { ...item };
}
);
messages.push({
role: "user",
content: updatedContent,
});
} else {
messages.push({
role: "user",
content: enhancedUserContent,
});
}
}
// Add current user question - support multimodal content if available
if (userMessage.content && Array.isArray(userMessage.content)) {
// For multimodal messages with images, replace the text content with processed text
const updatedContent = userMessage.content.map((item: any) => {
if (item.type === "text") {
// Use processed message text that includes context
return { ...item, text: userMessage.message };
}
return item;
});
messages.push({
role: "user",
content: updatedContent,
});
} else {
messages.push({
role: "user",
content: userMessage.message,
});
}
// Record the payload for debugging (includes layered view if envelope available)
const modelName = (chatModel as { modelName?: string } | undefined)?.modelName;
recordPromptPayload({
messages,
modelName,
contextEnvelope: userMessage.contextEnvelope,
});
logInfo("Final Request to AI:\n", messages);
// Stream with abort signal
const chatStream = await withSuppressedTokenWarnings(() =>
this.chainManager.chatModelManager.getChatModel().stream(messages, {
signal: abortController.signal,
})
this.chainManager.chatModelManager.getChatModel().stream(
// ProviderMessage[] format matches what getChatModel().stream() accepts at runtime
messages as never,
{
signal: abortController.signal,
}
)
);
for await (const chunk of chatStream) {
@ -156,20 +254,28 @@ export class VaultQAChainRunner extends BaseChainRunner {
logInfo("VaultQA stream iteration aborted", { reason: abortController.signal.reason });
break;
}
streamer.processChunk(chunk);
streamer.processChunk(chunk as Parameters<typeof streamer.processChunk>[0]);
}
} catch (error: any) {
} catch (error: unknown) {
// Check if the error is due to abort signal
if (error.name === "AbortError" || abortController.signal.aborted) {
if ((error as { name?: string }).name === "AbortError" || abortController.signal.aborted) {
logInfo("VaultQA stream aborted by user", { reason: abortController.signal.reason });
// Don't show error message for user-initiated aborts
} else {
await this.handleError(error, addMessage, updateCurrentAiMessage);
await this.handleError(
error,
streamer.processErrorChunk.bind(streamer) as (message: string) => void
);
}
}
// Always get the response, even if partial
let fullAIResponse = streamer.close();
const result = streamer.close();
const responseMetadata = {
wasTruncated: result.wasTruncated,
tokenUsage: result.tokenUsage ?? undefined,
};
// Only skip saving if it's a new chat (clearing everything)
if (abortController.signal.aborted && abortController.signal.reason === ABORT_REASON.NEW_CHAT) {
@ -178,22 +284,41 @@ export class VaultQAChainRunner extends BaseChainRunner {
}
// Add sources to the response
fullAIResponse = this.addSourcestoResponse(fullAIResponse);
const fullAIResponse = this.addSourcestoResponse(result.content);
return this.handleResponse(
await this.handleResponse(
fullAIResponse,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
updateCurrentAiMessage,
undefined,
undefined,
responseMetadata
);
return fullAIResponse;
}
private addSourcestoResponse(response: string): string {
const settings = getSettings();
// Only add sources if the AI actually cited them (has inline citations like [^1], [^2])
// Don't add fallback sources if there are no citations in the response
if (!hasInlineCitations(response)) {
return response;
}
const retrievedDocs = this.chainManager.getRetrievedDocuments();
const sources = extractUniqueTitlesFromDocs(retrievedDocs).map((title) => ({ title }));
return addFallbackSources(response, sources, settings.enableInlineCitations);
}
/**
* Extracts hash-prefixed tags from the current query so Vault QA can trigger tag-aware retrieval.
*/
private extractTagTerms(query: string): string[] {
return extractTagsFromQuery(query);
}
}

View file

@ -1,22 +1,7 @@
// Main exports for chain runners
export type { ChainRunner } from "./BaseChainRunner";
export { BaseChainRunner } from "./BaseChainRunner";
export { LLMChainRunner } from "./LLMChainRunner";
export { VaultQAChainRunner } from "./VaultQAChainRunner";
export { CopilotPlusChainRunner } from "./CopilotPlusChainRunner";
export { ProjectChainRunner } from "./ProjectChainRunner";
export { AutonomousAgentChainRunner } from "./AutonomousAgentChainRunner";
// Utility exports (for internal use or testing)
export { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
export { parseXMLToolCalls, stripToolCallXML } from "./utils/xmlParsing";
export type { ToolCall } from "./utils/xmlParsing";
export {
executeSequentialToolCall,
getToolDisplayName,
getToolEmoji,
logToolCall,
logToolResult,
deduplicateSources,
} from "./utils/toolExecution";
export type { ToolExecutionResult } from "./utils/toolExecution";

View file

@ -10,22 +10,22 @@ const MockedToolManager = ToolManager as jest.Mocked<typeof ToolManager>;
const MockedToolResultFormatter = ToolResultFormatter as jest.Mocked<typeof ToolResultFormatter>;
describe("ActionBlockStreamer", () => {
let writeToFileTool: any;
let writeFileTool: unknown;
let streamer: ActionBlockStreamer;
beforeEach(() => {
writeToFileTool = { name: "writeToFile" };
writeFileTool = { name: "writeFile" };
MockedToolManager.callTool.mockClear();
// Mock ToolResultFormatter to return the raw result without "File change result: " prefix
MockedToolResultFormatter.format = jest.fn((_toolName, result) => result);
streamer = new ActionBlockStreamer(MockedToolManager, writeToFileTool);
streamer = new ActionBlockStreamer(MockedToolManager, writeFileTool);
});
// Helper function to process chunks and collect results
async function processChunks(chunks: { content: string | null }[]) {
const outputContents: any[] = [];
async function processChunks(chunks: { content: string | null }[]): Promise<unknown[]> {
const outputContents: unknown[] = [];
for (const chunk of chunks) {
for await (const result of streamer.processChunk(chunk)) {
// Always push the content, even if it's null, undefined, or empty string
@ -35,7 +35,7 @@ describe("ActionBlockStreamer", () => {
return outputContents;
}
it("should pass through chunks without writeToFile tags unchanged", async () => {
it("should pass through chunks without writeFile tags unchanged", async () => {
const chunks = [{ content: "Hello " }, { content: "world, this is " }, { content: "a test." }];
const output = await processChunks(chunks);
@ -46,97 +46,97 @@ describe("ActionBlockStreamer", () => {
expect(MockedToolManager.callTool).not.toHaveBeenCalled();
});
it("should handle a complete writeToFile block in a single chunk", async () => {
it("should handle a complete writeFile block in a single chunk", async () => {
MockedToolManager.callTool.mockResolvedValue("File written successfully.");
const chunks = [
{
content:
"Some text before <writeToFile><path>file.txt</path><content>content</content></writeToFile> and some text after.",
"Some text before <writeFile><path>file.txt</path><content>content</content></writeFile> and some text after.",
},
];
const output = await processChunks(chunks);
// Should yield original chunk plus tool result
expect(output).toEqual([
"Some text before <writeToFile><path>file.txt</path><content>content</content></writeToFile> and some text after.",
"Some text before <writeFile><path>file.txt</path><content>content</content></writeFile> and some text after.",
"\nFile written successfully.\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "file.txt",
content: "content",
});
});
it("should handle a complete xml-wrapped writeToFile block", async () => {
it("should handle a complete xml-wrapped writeFile block", async () => {
MockedToolManager.callTool.mockResolvedValue("XML file written.");
const chunks = [
{
content:
"```xml\n<writeToFile><path>file.xml</path><content>xml content</content></writeToFile>\n```",
"```xml\n<writeFile><path>file.xml</path><content>xml content</content></writeFile>\n```",
},
];
const output = await processChunks(chunks);
expect(output).toEqual([
"```xml\n<writeToFile><path>file.xml</path><content>xml content</content></writeToFile>\n```",
"```xml\n<writeFile><path>file.xml</path><content>xml content</content></writeFile>\n```",
"\nXML file written.\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "file.xml",
content: "xml content",
});
});
it("should handle a writeToFile block split across multiple chunks", async () => {
it("should handle a writeFile block split across multiple chunks", async () => {
MockedToolManager.callTool.mockResolvedValue("Split file written.");
const chunks = [
{ content: "Here is a file <writeToFile><path>split.txt</path>" },
{ content: "Here is a file <writeFile><path>split.txt</path>" },
{ content: "<content>split content</content>" },
{ content: "</writeToFile> That was it." },
{ content: "</writeFile> That was it." },
];
const output = await processChunks(chunks);
// All chunks should be yielded as-is, plus tool result when complete block is detected
expect(output).toEqual([
"Here is a file <writeToFile><path>split.txt</path>",
"Here is a file <writeFile><path>split.txt</path>",
"<content>split content</content>",
"</writeToFile> That was it.",
"</writeFile> That was it.",
"\nSplit file written.\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "split.txt",
content: "split content",
});
});
it("should handle multiple writeToFile blocks in the stream", async () => {
it("should handle multiple writeFile blocks in the stream", async () => {
MockedToolManager.callTool
.mockResolvedValueOnce("File 1 written.")
.mockResolvedValueOnce("File 2 written.");
const chunks = [
{
content:
"<writeToFile><path>f1.txt</path><content>c1</content></writeToFile>Some text<writeToFile><path>f2.txt</path><content>c2</content></writeToFile>",
"<writeFile><path>f1.txt</path><content>c1</content></writeFile>Some text<writeFile><path>f2.txt</path><content>c2</content></writeFile>",
},
];
const output = await processChunks(chunks);
// Should yield original chunk plus both tool results
expect(output).toEqual([
"<writeToFile><path>f1.txt</path><content>c1</content></writeToFile>Some text<writeToFile><path>f2.txt</path><content>c2</content></writeToFile>",
"<writeFile><path>f1.txt</path><content>c1</content></writeFile>Some text<writeFile><path>f2.txt</path><content>c2</content></writeFile>",
"\nFile 1 written.\n",
"\nFile 2 written.\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledTimes(2);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "f1.txt",
content: "c1",
});
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "f2.txt",
content: "c2",
});
@ -144,14 +144,14 @@ describe("ActionBlockStreamer", () => {
it("should handle unclosed tags without calling tools", async () => {
const chunks = [
{ content: "Starting... <writeToFile><path>unclosed.txt</path>" },
{ content: "Starting... <writeFile><path>unclosed.txt</path>" },
{ content: "<content>this will not be closed" },
];
const output = await processChunks(chunks);
// Should yield all chunks as-is
expect(output).toEqual([
"Starting... <writeToFile><path>unclosed.txt</path>",
"Starting... <writeFile><path>unclosed.txt</path>",
"<content>this will not be closed",
]);
@ -163,24 +163,24 @@ describe("ActionBlockStreamer", () => {
MockedToolManager.callTool.mockRejectedValue(new Error("Tool error"));
const chunks = [
{
content: "<writeToFile><path>error.txt</path><content>content</content></writeToFile>",
content: "<writeFile><path>error.txt</path><content>content</content></writeFile>",
},
];
const output = await processChunks(chunks);
expect(output).toEqual([
"<writeToFile><path>error.txt</path><content>content</content></writeToFile>",
"<writeFile><path>error.txt</path><content>content</content></writeFile>",
"\nError: Tool error\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "error.txt",
content: "content",
});
});
it("should handle chunks with different content types", async () => {
const chunks: any[] = [
const chunks: { content: string | null }[] = [
{ content: "Hello" },
{ content: null },
{ content: "" },
@ -197,12 +197,12 @@ describe("ActionBlockStreamer", () => {
const chunks = [
{
content:
"<writeToFile><path> spaced.txt </path><content> content with spaces </content></writeToFile>",
"<writeFile><path> spaced.txt </path><content> content with spaces </content></writeFile>",
},
];
await processChunks(chunks);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "spaced.txt",
content: "content with spaces",
});
@ -212,19 +212,19 @@ describe("ActionBlockStreamer", () => {
MockedToolManager.callTool.mockResolvedValue("Malformed handled.");
const chunks = [
{
content: "<writeToFile><path>missing-content.txt</path></writeToFile>",
content: "<writeFile><path>missing-content.txt</path></writeFile>",
},
];
const output = await processChunks(chunks);
// Should yield chunk as-is plus tool result
expect(output).toEqual([
"<writeToFile><path>missing-content.txt</path></writeToFile>",
"<writeFile><path>missing-content.txt</path></writeFile>",
"\nMalformed handled.\n",
]);
// Tool should be called with undefined content
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "missing-content.txt",
content: undefined,
});

View file

@ -2,11 +2,11 @@ import { ToolManager } from "@/tools/toolManager";
import { ToolResultFormatter } from "@/tools/ToolResultFormatter";
/**
* ActionBlockStreamer processes streaming chunks to detect and handle writeToFile blocks.
* ActionBlockStreamer processes streaming chunks to detect and handle writeFile blocks.
*
* 1. Accumulates chunks in a buffer
* 2. Detects complete writeToFile blocks
* 3. Calls the writeToFile tool when a complete block is found
* 2. Detects complete writeFile blocks
* 3. Calls the writeFile tool when a complete block is found
* 4. Returns chunks as-is otherwise
*/
export class ActionBlockStreamer {
@ -14,12 +14,12 @@ export class ActionBlockStreamer {
constructor(
private toolManager: typeof ToolManager,
private writeToFileTool: any
private writeFileTool: unknown
) {}
private findCompleteBlock(str: string) {
// Regex for both formats
const regex = /<writeToFile>[\s\S]*?<\/writeToFile>/;
const regex = /<writeFile>[\s\S]*?<\/writeFile>/;
const match = str.match(regex);
if (!match || match.index === undefined) {
@ -32,21 +32,23 @@ export class ActionBlockStreamer {
};
}
async *processChunk(chunk: any): AsyncGenerator<any, void, unknown> {
async *processChunk(
chunk: Record<string, unknown>
): AsyncGenerator<Record<string, unknown>, void, unknown> {
// Handle different chunk formats
let chunkContent = "";
// Handle Claude thinking model array-based content
if (Array.isArray(chunk.content)) {
for (const item of chunk.content) {
for (const item of chunk.content as Array<{ type?: string; text?: unknown }>) {
if (item.type === "text" && item.text != null) {
chunkContent += item.text;
chunkContent += typeof item.text === "string" ? item.text : "";
}
}
}
// Handle standard string content
else if (chunk.content != null) {
chunkContent = chunk.content;
chunkContent = typeof chunk.content === "string" ? chunk.content : "";
}
// Add to buffer
@ -71,16 +73,16 @@ export class ActionBlockStreamer {
// Call the tool
try {
const result = await this.toolManager.callTool(this.writeToFileTool, {
const result = await this.toolManager.callTool(this.writeFileTool, {
path: filePath,
content: fileContent,
});
// Format tool result using ToolResultFormatter for consistency with agent mode
const formattedResult = ToolResultFormatter.format("writeToFile", result);
const formattedResult = ToolResultFormatter.format("writeFile", result as string);
yield { ...chunk, content: `\n${formattedResult}\n` };
} catch (err: any) {
yield { ...chunk, content: `\nError: ${err?.message || err}\n` };
} catch (err: unknown) {
yield { ...chunk, content: `\nError: ${(err as Error)?.message ?? String(err)}\n` };
}
// Remove processed block from buffer

View file

@ -0,0 +1,93 @@
import { summarizeToolCall, summarizeToolResult } from "./AgentReasoningState";
describe("AgentReasoningState tool summaries", () => {
test("summarizeToolCall has daily/random CLI specific wording", () => {
expect(summarizeToolCall("obsidianDailyNote", { command: "daily:read" })).toBe(
"Reading today's daily note"
);
expect(summarizeToolCall("obsidianDailyNote", { command: "daily:read", vault: "Work" })).toBe(
`Reading today's daily note from "Work"`
);
expect(summarizeToolCall("obsidianDailyNote", { command: "daily:path" })).toBe(
"Getting daily note path"
);
expect(summarizeToolCall("obsidianRandomRead")).toBe("Reading a random note");
expect(summarizeToolCall("obsidianRandomRead", { vault: "Personal" })).toBe(
`Reading a random note from "Personal"`
);
});
test("summarizeToolResult has daily/random CLI specific wording", () => {
expect(
summarizeToolResult("obsidianDailyNote", { success: true }, undefined, {
command: "daily:read",
vault: "Work",
})
).toBe(`Loaded today's daily note from "Work"`);
expect(
summarizeToolResult("obsidianDailyNote", { success: true }, undefined, {
command: "daily:read",
})
).toBe("Loaded today's daily note");
expect(
summarizeToolResult("obsidianDailyNote", { success: true }, undefined, {
command: "daily:path",
vault: "Work",
})
).toBe(`Got daily note path from "Work"`);
expect(
summarizeToolResult("obsidianRandomRead", { success: true }, undefined, { vault: "Personal" })
).toBe(`Loaded a random note from "Personal"`);
expect(summarizeToolResult("obsidianRandomRead", { success: true })).toBe(
"Loaded a random note"
);
});
test("summarizeToolResult failure path reuses CLI call summary", () => {
expect(
summarizeToolResult("obsidianRandomRead", { success: false }, undefined, { vault: "VaultA" })
).toBe(`Reading a random note from "VaultA" failed`);
});
test("summarizeToolCall has properties/tasks/links CLI specific wording", () => {
expect(summarizeToolCall("obsidianProperties", { command: "properties" })).toBe(
"Listing vault properties"
);
expect(
summarizeToolCall("obsidianProperties", { command: "property:read", name: "tags" })
).toBe(`Reading property "tags"`);
expect(summarizeToolCall("obsidianTasks", { command: "tasks" })).toBe("Listing vault tasks");
expect(summarizeToolCall("obsidianLinks", { command: "backlinks" })).toBe("Listing backlinks");
expect(summarizeToolCall("obsidianLinks", { command: "orphans" })).toBe(
"Listing orphaned notes"
);
expect(summarizeToolCall("obsidianLinks", { command: "unresolved" })).toBe(
"Listing unresolved links"
);
});
test("summarizeToolResult has properties/tasks/links CLI specific wording", () => {
expect(
summarizeToolResult("obsidianProperties", { success: true }, undefined, {
command: "properties",
})
).toBe("Listed vault properties");
expect(
summarizeToolResult("obsidianProperties", { success: true }, undefined, {
command: "property:read",
name: "tags",
})
).toBe(`Read property "tags"`);
expect(
summarizeToolResult("obsidianTasks", { success: true }, undefined, { command: "tasks" })
).toBe("Listed vault tasks");
expect(
summarizeToolResult("obsidianLinks", { success: true }, undefined, { command: "backlinks" })
).toBe("Listed backlinks");
expect(
summarizeToolResult("obsidianLinks", { success: true }, undefined, { command: "orphans" })
).toBe("Listed orphaned notes");
});
});

View file

@ -0,0 +1,471 @@
/**
* Agent Reasoning Block State Management
*
* This module provides state management for the Agent Reasoning Block UI component,
* which replaces the old tool call banner with a more informative reasoning display.
*/
/**
* Represents a single reasoning step in the agent loop
*/
export interface ReasoningStep {
timestamp: number;
summary: string;
toolName?: string;
}
/**
* Status of the reasoning block
* - idle: No agent activity
* - reasoning: Agent is actively processing/executing tools
* - collapsed: Reasoning complete, block is collapsed
* - complete: Response complete, block can be expanded
*/
export type ReasoningStatus = "idle" | "reasoning" | "collapsed" | "complete";
/**
* Full state for the Agent Reasoning Block
*/
export interface AgentReasoningState {
status: ReasoningStatus;
startTime: number | null;
elapsedSeconds: number;
steps: ReasoningStep[];
}
/**
* Creates the initial reasoning state
*/
export function createInitialReasoningState(): AgentReasoningState {
return {
status: "idle",
startTime: null,
elapsedSeconds: 0,
steps: [],
};
}
/**
* Data structure for serialized reasoning block (embedded in message)
*/
export interface SerializedReasoningData {
elapsed: number;
steps: string[];
}
/**
* Serialize reasoning state to a marker format for embedding in messages.
* Format: <!--AGENT_REASONING:status:elapsedSeconds:["step1","step2"]-->
*
* @param state - The reasoning state to serialize
* @returns Marker string to embed in message
*/
export function serializeReasoningBlock(state: AgentReasoningState): string {
if (state.status === "idle") {
return "";
}
const stepsJson = JSON.stringify(state.steps.map((s) => s.summary));
return `<!--AGENT_REASONING:${state.status}:${state.elapsedSeconds}:${stepsJson}-->`;
}
/**
* Parsed reasoning data from a marker
*/
export interface ParsedReasoningBlock {
hasReasoning: boolean;
status: ReasoningStatus;
elapsedSeconds: number;
steps: string[];
contentAfter: string;
}
/**
* Parse reasoning block marker from message content.
*
* @param content - Message content that may contain reasoning marker
* @returns Parsed reasoning data or null if no marker found
*/
export function parseReasoningBlock(content: string): ParsedReasoningBlock | null {
const match = content.match(/<!--AGENT_REASONING:(\w+):(\d+):(.+?)-->/);
if (!match) {
return null;
}
const [fullMatch, status, elapsed, stepsJson] = match;
let steps: string[] = [];
try {
steps = JSON.parse(stepsJson) as string[];
} catch {
// Invalid JSON, return empty steps
steps = [];
}
return {
hasReasoning: true,
status: status as ReasoningStatus,
elapsedSeconds: parseInt(elapsed, 10),
steps,
contentAfter: content.replace(fullMatch, "").trim(),
};
}
/**
* Query expansion info for localSearch.
* Contains both the individual expansion components and a combined list of all recall terms.
*/
export interface QueryExpansionInfo {
originalQuery: string;
salientTerms: string[]; // Terms from original query (used for ranking)
expandedQueries: string[]; // Alternative phrasings (used for recall)
recallTerms: string[]; // All terms combined that were used for recall
}
/**
* Source info for localSearch results
*/
export interface LocalSearchSourceInfo {
titles: string[];
count: number;
queryExpansion?: QueryExpansionInfo;
}
/**
* Generate a human-readable summary for a tool result.
*
* @param toolName - Name of the tool that was executed
* @param result - Result from the tool execution
* @param sourceInfo - Optional source info for localSearch results
* @param args - Optional original tool call arguments for context
* @returns Human-readable summary string
*/
export function summarizeToolResult(
toolName: string,
result: { success: boolean; result?: string },
sourceInfo?: LocalSearchSourceInfo,
args?: Record<string, unknown>
): string {
if (!result.success) {
// Reuse the human-friendly call summary (e.g., "Searching notes") → "Searching notes failed"
return `${summarizeToolCall(toolName, args)} failed`;
}
switch (toolName) {
case "localSearch": {
if (sourceInfo && sourceInfo.count > 0) {
// Show just the count and first few note titles (terms are shown in tool call summary)
const titleList = sourceInfo.titles.slice(0, 3);
const remaining = sourceInfo.count - titleList.length;
let result = `Found ${sourceInfo.count} note${sourceInfo.count !== 1 ? "s" : ""}: ${titleList.join(", ")}`;
if (remaining > 0) {
result += ` +${remaining} more`;
}
return result;
}
return "No matching notes found";
}
case "webSearch":
return "Retrieved web search results";
case "getTimeRangeMs":
return "Calculated time range";
case "readFile":
return "Read file content";
case "readNote": {
const notePath = args?.notePath as string | undefined;
if (notePath) {
const noteTitle = notePath.split("/").pop()?.replace(/\.md$/i, "") || notePath;
return `Read "${noteTitle}"`;
}
return "Read note content";
}
case "createNote":
return "Created new note";
case "appendToNote":
return "Appended to note";
case "editNote":
return "Edited note";
case "deleteNote":
return "Deleted note";
case "youtubeTranscript":
case "youtubeTranscription":
return "Fetched video transcript";
case "fetchUrl":
return "Fetched URL content";
case "getFileTree":
return "Retrieved vault file tree";
case "getTagList":
return "Retrieved vault tags";
case "getCurrentTime":
return "Got current time";
case "getTimeInfoByEpoch":
return "Converted timestamp";
case "convertTimeBetweenTimezones":
return "Converted timezone";
case "obsidianDailyNote": {
const command = args?.command as string | undefined;
const vault = args?.vault as string | undefined;
const vaultSuffix = vault && vault.trim().length > 0 ? ` from "${vault}"` : "";
if (command === "daily:path") return `Got daily note path${vaultSuffix}`;
return `Loaded today's daily note${vaultSuffix}`;
}
case "obsidianRandomRead": {
const vault = args?.vault as string | undefined;
if (vault && vault.trim().length > 0) {
return `Loaded a random note from "${vault}"`;
}
return "Loaded a random note";
}
case "obsidianProperties": {
const command = args?.command as string | undefined;
if (command === "property:read") {
const name = args?.name as string | undefined;
return name ? `Read property "${name}"` : "Read property";
}
return "Listed vault properties";
}
case "obsidianTasks":
return "Listed vault tasks";
case "obsidianLinks": {
const command = args?.command as string | undefined;
if (command === "backlinks") return "Listed backlinks";
if (command === "links") return "Listed outgoing links";
if (command === "orphans") return "Listed orphaned notes";
if (command === "unresolved") return "Listed unresolved links";
return "Queried link graph";
}
case "obsidianTemplates": {
const command = args?.command as string | undefined;
if (command === "template:read") {
const name = args?.name as string | undefined;
return name ? `Read template "${name}"` : "Read template";
}
return "Listed templates";
}
case "obsidianBases": {
const command = args?.command as string | undefined;
if (command === "base:views") return "Listed base views";
if (command === "base:query") return "Queried base data";
return "Listed bases";
}
case "indexVault":
return "Indexed vault";
case "updateMemory":
return "Updated memory";
case "writeFile":
case "editFile": {
// Parse the result to check if accepted/rejected
const filePath = args?.path as string | undefined;
const fileName = filePath ? filePath.split("/").pop() || filePath : "file";
// Result is JSON string, check for rejected/accepted status
const resultStr = result.result || "";
if (resultStr.includes('"rejected"') || resultStr.includes("rejected")) {
return `Edit rejected for "${fileName}"`;
}
if (resultStr.includes('"failed"') || resultStr.includes("Error")) {
return `Edit failed for "${fileName}"`;
}
// TODO(@wenzhengjiang): Handle no-op cases (e.g., "File is too small", "Search text not found")
// Requires ComposerTools to return structured results instead of plain strings.
// See docs/TODO-composer-tool-redesign.md
return toolName === "writeFile" ? `Wrote to "${fileName}"` : `Edited "${fileName}"`;
}
default:
return "Done";
}
}
// TODO: The `expansion` parameter and QueryExpansionInfo interface are now dead code --
// the agent runner no longer pre-expands queries. Clean up the expansion branch below
// and the _preExpandedQuery schema field in SearchTools.ts.
/**
* Generate a summary for when a tool is being called.
*
* @param toolName - Name of the tool being called
* @param args - Arguments being passed to the tool
* @param expansion - Optional pre-expanded query data for localSearch
* @returns Human-readable summary string
*/
export function summarizeToolCall(
toolName: string,
args?: Record<string, unknown>,
expansion?: QueryExpansionInfo
): string {
switch (toolName) {
case "localSearch": {
// If we have pre-expanded terms, show all recall terms
if (expansion && expansion.recallTerms && expansion.recallTerms.length > 0) {
// Filter to valid strings only, excluding "[object Object]" artifacts
const validTerms = expansion.recallTerms.filter(
(t): t is string =>
typeof t === "string" &&
t.trim().length > 0 &&
!t.includes("[object ") &&
t !== "[object Object]"
);
if (validTerms.length > 0) {
const terms = validTerms
.slice(0, 6)
.map((t) => `"${t}"`)
.join(", ");
const moreCount = validTerms.length - 6;
const termsSuffix = moreCount > 0 ? ` +${moreCount} more` : "";
return `Searching notes for ${terms}${termsSuffix}`;
}
}
// Fallback to query if no expansion available
const query = args?.query as string | undefined;
if (query) {
const truncatedQuery = query.length > 50 ? query.slice(0, 50) + "..." : query;
return `Searching notes for "${truncatedQuery}"`;
}
return "Searching notes";
}
case "webSearch": {
const query = args?.query as string | undefined;
if (query) {
const truncatedQuery = query.length > 30 ? query.slice(0, 30) + "..." : query;
return `Searching web for "${truncatedQuery}"`;
}
return "Searching the web";
}
case "getTimeRangeMs":
return "Calculating time range";
case "readFile": {
const path = args?.path as string | undefined;
if (path) {
const fileName = path.split("/").pop() || path;
return `Reading "${fileName}"`;
}
return "Reading file";
}
case "readNote": {
const notePath = args?.notePath as string | undefined;
if (notePath) {
// Extract note title from path (remove .md extension and get last segment)
const noteTitle = notePath.split("/").pop()?.replace(/\.md$/i, "") || notePath;
return `Reading "${noteTitle}"`;
}
return "Reading note";
}
case "createNote":
return "Creating new note";
case "appendToNote":
return "Appending to note";
case "editNote":
return "Editing note";
case "deleteNote":
return "Deleting note";
case "youtubeTranscript":
case "youtubeTranscription":
return "Fetching video transcript";
case "fetchUrl":
return "Fetching URL content";
case "getFileTree":
return "Browsing vault file tree";
case "getTagList":
return "Loading vault tags";
case "getCurrentTime":
return "Getting current time";
case "getTimeInfoByEpoch":
return "Converting timestamp";
case "convertTimeBetweenTimezones":
return "Converting timezone";
case "obsidianDailyNote": {
const command = args?.command as string | undefined;
const vault = args?.vault as string | undefined;
const vaultSuffix = vault && vault.trim().length > 0 ? ` from "${vault}"` : "";
if (command === "daily:path") return `Getting daily note path${vaultSuffix}`;
return `Reading today's daily note${vaultSuffix}`;
}
case "obsidianRandomRead": {
const vault = args?.vault as string | undefined;
if (vault && vault.trim().length > 0) {
return `Reading a random note from "${vault}"`;
}
return "Reading a random note";
}
case "obsidianProperties": {
const command = args?.command as string | undefined;
if (command === "property:read") {
const name = args?.name as string | undefined;
return name ? `Reading property "${name}"` : "Reading property";
}
return "Listing vault properties";
}
case "obsidianTasks":
return "Listing vault tasks";
case "obsidianLinks": {
const command = args?.command as string | undefined;
if (command === "backlinks") return "Listing backlinks";
if (command === "links") return "Listing outgoing links";
if (command === "orphans") return "Listing orphaned notes";
if (command === "unresolved") return "Listing unresolved links";
return "Querying link graph";
}
case "obsidianTemplates": {
const command = args?.command as string | undefined;
if (command === "template:read") {
const name = args?.name as string | undefined;
return name ? `Reading template "${name}"` : "Reading template";
}
return "Listing templates";
}
case "obsidianBases": {
const command = args?.command as string | undefined;
if (command === "base:views") return "Listing base views";
if (command === "base:query") return "Querying base data";
return "Listing bases";
}
case "indexVault":
return "Indexing vault";
case "updateMemory":
return "Saving to memory";
case "writeFile": {
const filePath = args?.path as string | undefined;
if (filePath) {
const fileName = filePath.split("/").pop() || filePath;
return `Writing to "${fileName}"`;
}
return "Writing to file";
}
case "editFile": {
const filePath = args?.path as string | undefined;
if (filePath) {
const fileName = filePath.split("/").pop() || filePath;
return `Editing "${fileName}"`;
}
return "Editing file";
}
default:
return "Processing";
}
}
/**
* Truncate text to a maximum length, adding ellipsis if needed.
*/
function truncate(text: string, maxLen: number): string {
return text.length > maxLen ? text.slice(0, maxLen - 3) + "..." : text;
}
/**
* Extract the first sentence from model's intermediate reasoning content.
* Used to show what the model found/concluded from previous tool calls.
*
* Uses a simple approach: take first line, truncate if needed.
* This avoids edge cases with abbreviations (Dr., U.S.) that confuse regex-based
* sentence detection.
*
* @param content - Model's intermediate content (may contain reasoning about findings)
* @returns First line/sentence if found, null otherwise
*/
export function extractFirstSentence(content: string): string | null {
if (!content || content.trim().length === 0) {
return null;
}
const firstLine = content.trim().split("\n")[0];
return truncate(firstLine, 100);
}

View file

@ -0,0 +1,413 @@
import { ThinkBlockStreamer } from "./ThinkBlockStreamer";
describe("ThinkBlockStreamer", () => {
describe("OpenRouter delta.reasoning format", () => {
it("should NOT treat empty reasoning_details array as thinking content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// This was the bug: empty reasoning_details array should not trigger thinking mode
streamer.processChunk({
content: "Regular content",
additional_kwargs: {
reasoning_details: [],
},
});
// Should NOT have <think> tags since reasoning_details is empty
expect(currentMessage).toBe("Regular content");
expect(currentMessage).not.toContain("<think>");
});
it("should handle delta.reasoning for streaming", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// First chunk with delta.reasoning
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "Thinking step 1: ",
},
},
});
expect(currentMessage).toBe("\n<think>Thinking step 1: ");
// Second chunk with more delta.reasoning
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "Thinking step 2.",
},
},
});
expect(currentMessage).toBe("\n<think>Thinking step 1: Thinking step 2.");
// Regular content should close think block
streamer.processChunk({
content: "Here's the result.",
additional_kwargs: {},
});
expect(currentMessage).toBe(
"\n<think>Thinking step 1: Thinking step 2.</think>Here's the result."
);
});
it("should NOT duplicate when both delta.reasoning and reasoning_details are present", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// First chunk: delta.reasoning with streaming token
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "Analyzing the ",
},
},
});
expect(currentMessage).toBe("\n<think>Analyzing the ");
// Second chunk: more delta.reasoning
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "question carefully.",
},
},
});
expect(currentMessage).toBe("\n<think>Analyzing the question carefully.");
// Final chunk: reasoning_details with complete transcript (should be IGNORED)
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [
{
text: "Analyzing the question carefully.", // Same content as accumulated delta
},
],
},
});
// Should NOT duplicate - reasoning_details should be ignored since we've seen delta.reasoning
expect(currentMessage).toBe("\n<think>Analyzing the question carefully.");
expect(currentMessage).not.toContain(
"Analyzing the question carefully.Analyzing the question carefully."
);
// Regular content
streamer.processChunk({
content: "Here's my answer.",
additional_kwargs: {},
});
expect(currentMessage).toBe(
"\n<think>Analyzing the question carefully.</think>Here's my answer."
);
});
});
describe("Claude array-based format", () => {
it("should handle Claude's content array with thinking type", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// Claude format with content array
streamer.processChunk({
content: [
{
type: "thinking",
thinking: "Let me analyze this...",
},
],
});
expect(currentMessage).toBe("\n<think>Let me analyze this...");
// Text content in array
streamer.processChunk({
content: [
{
type: "text",
text: "Based on my analysis, ",
},
],
});
expect(currentMessage).toBe("\n<think>Let me analyze this...</think>Based on my analysis, ");
});
it("should guard against undefined thinking content in Claude format", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// Malformed chunk with undefined thinking
streamer.processChunk({
content: [
{
type: "thinking",
// thinking property is undefined
},
],
});
// Should not crash, and should not add undefined to response
expect(currentMessage).toBe("\n<think>");
expect(currentMessage).not.toContain("undefined");
});
});
describe("Deepseek format", () => {
it("should handle Deepseek reasoning_content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_content: "Deepseek is thinking...",
},
});
expect(currentMessage).toBe("\n<think>Deepseek is thinking...");
streamer.processChunk({
content: "The answer is here.",
additional_kwargs: {},
});
expect(currentMessage).toBe("\n<think>Deepseek is thinking...</think>The answer is here.");
});
it("should guard against undefined reasoning_content in Deepseek format", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// Malformed chunk with undefined reasoning_content
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_content: undefined,
},
});
// Should not crash, not open think block, and not add undefined
expect(currentMessage).toBe("");
expect(currentMessage).not.toContain("undefined");
expect(currentMessage).not.toContain("<think>");
});
it("should handle streaming Deepseek reasoning_content without premature closure", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// First chunk with reasoning_content
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_content: "Thinking step 1...",
},
});
expect(currentMessage).toBe("\n<think>Thinking step 1...");
// Second chunk with MORE reasoning_content (streaming)
// This should NOT close and reopen the think block
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_content: " Step 2...",
},
});
// Should be continuous, NOT "</think>\n<think>"
expect(currentMessage).toBe("\n<think>Thinking step 1... Step 2...");
expect(currentMessage).not.toContain("</think>\n<think>");
// Third chunk with regular content
streamer.processChunk({
content: "Final answer.",
additional_kwargs: {},
});
expect(currentMessage).toBe("\n<think>Thinking step 1... Step 2...</think>Final answer.");
});
});
describe("excludeThinking option", () => {
it("should skip OpenRouter thinking content when excludeThinking is true", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer(
(msg) => {
currentMessage = msg;
},
true // excludeThinking = true
);
// Thinking content should be skipped
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "This should be skipped",
},
},
});
expect(currentMessage).toBe("");
// Regular content should still be processed
streamer.processChunk({
content: "This should be included",
additional_kwargs: {},
});
expect(currentMessage).toBe("This should be included");
});
it("should skip Claude thinking content when excludeThinking is true", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer(
(msg) => {
currentMessage = msg;
},
true // excludeThinking = true
);
streamer.processChunk({
content: [
{
type: "thinking",
thinking: "Claude thinking",
},
],
});
expect(currentMessage).toBe("");
expect(currentMessage).not.toContain("<think>");
});
});
describe("close() method", () => {
it("should close any open think block at the end", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "Thinking...",
},
},
});
expect(currentMessage).toBe("\n<think>Thinking...");
const result = streamer.close();
expect(result.content).toBe("\n<think>Thinking...</think>");
});
it("should not add extra closing tag if already closed", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "Thinking...",
},
},
});
streamer.processChunk({
content: "Done",
additional_kwargs: {},
});
expect(currentMessage).toBe("\n<think>Thinking...</think>Done");
const result = streamer.close();
expect(result.content).toBe("\n<think>Thinking...</think>Done");
// Should not have double closing tags
expect(result.content.match(/<\/think>/g)?.length).toBe(1);
});
});
describe("mixed content scenarios", () => {
it("should handle rapid alternation between thinking and regular content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
const chunks = [
{ thinking: "Think 1", content: "" },
{ thinking: "", content: "Text 1" },
{ thinking: "Think 2", content: "" },
{ thinking: "", content: "Text 2" },
{ thinking: "Think 3", content: "" },
{ thinking: "", content: "Text 3" },
];
chunks.forEach((chunk) => {
if (chunk.thinking) {
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: chunk.thinking,
},
},
});
} else {
streamer.processChunk({
content: chunk.content,
additional_kwargs: {},
});
}
});
// Should have three separate think blocks
const thinkMatches = currentMessage.match(/<think>/g);
const thinkCloseMatches = currentMessage.match(/<\/think>/g);
expect(thinkMatches?.length).toBe(3);
expect(thinkCloseMatches?.length).toBe(3);
// Each text should be outside think blocks
expect(currentMessage).toContain("</think>Text 1");
expect(currentMessage).toContain("</think>Text 2");
expect(currentMessage).toContain("</think>Text 3");
});
});
});

View file

@ -1,27 +1,139 @@
import { ModelAdapter } from "./modelAdapter";
import { StreamingResult, TokenUsage } from "@/types/message";
import { AIMessage } from "@langchain/core/messages";
import { detectTruncation, extractTokenUsage } from "./finishReasonDetector";
import { formatErrorChunk } from "@/utils/toolResultUtils";
import {
NativeToolCall,
ToolCallChunk,
buildToolCallsFromChunks,
createAIMessageWithToolCalls,
} from "./nativeToolCalling";
import { logInfo, logWarn } from "@/logger";
import { stripSpecialTokens } from "@/utils/stripSpecialTokens";
/**
* ThinkBlockStreamer handles streaming content from various LLM providers
* that support thinking/reasoning modes (like Claude and Deepseek).
* Also accumulates native tool calls from tool_call_chunks during streaming.
* Also detects truncation due to token limits across all providers.
*/
export class ThinkBlockStreamer {
private hasOpenThinkBlock = false;
private fullResponse = "";
private shouldTruncate = false;
private errorResponse = "";
private wasTruncated = false;
private tokenUsage: TokenUsage | null = null;
// Track if we've handled text-level think tags (e.g., from nvidia/nemotron)
private hasHandledTextLevelThinkTag = false;
// Character index where an excluded text-level think block started.
// -1 means we're not currently inside an excluded block.
private excludedThinkBlockStart = -1;
// Native tool call accumulation
private toolCallChunks: Map<number, ToolCallChunk> = new Map();
private accumulatedToolCalls: NativeToolCall[] = [];
constructor(
private updateCurrentAiMessage: (message: string) => void,
private modelAdapter?: ModelAdapter
) {}
private excludeThinking: boolean = false
) {
logInfo(`[ThinkBlockStreamer] Created with excludeThinking=${excludeThinking}`);
}
private handleClaude37Chunk(content: any[]) {
/**
* Handle text-level think tags embedded in content (e.g., nvidia/nemotron, qwen3 models).
* Some models output thinking with only </think> closing tag, no opening tag.
* This method detects and fixes this during streaming.
*
* When excludeThinking is true, thinking content is suppressed during streaming
* (not just stripped after the close tag arrives) by tracking the position where
* the excluded block started and truncating fullResponse on each chunk.
*/
private handleTextLevelThinkTags() {
if (this.excludeThinking) {
this.handleExcludedThinkTags();
return;
}
const hasCloseTag = this.fullResponse.includes("</think>");
const hasOpenTag = this.fullResponse.includes("<think>");
if (!hasCloseTag) return;
// excludeThinking is false - fix missing opening tag if needed
if (!hasOpenTag && !this.hasHandledTextLevelThinkTag) {
this.hasHandledTextLevelThinkTag = true;
logWarn(
"Detected </think> closing tag without opening <think> tag. " +
"This may indicate a misconfigured chat template in LM Studio. Adding opening tag."
);
this.fullResponse = "<think>" + this.fullResponse;
}
}
/**
* Strip text-level think tags when excludeThinking is true.
* Handles three streaming states:
* 1. Not inside a think block -- look for `<think>` to enter one
* 2. Inside an incomplete block (no `</think>` yet) -- truncate to pre-block content
* 3. Block just closed -- strip the complete block and exit the state
*/
private handleExcludedThinkTags() {
// Currently inside an excluded block from a previous chunk
if (this.excludedThinkBlockStart >= 0) {
const closeIdx = this.fullResponse.indexOf("</think>", this.excludedThinkBlockStart);
if (closeIdx !== -1) {
// Block closed -- stitch content before and after the block
const before = this.fullResponse.substring(0, this.excludedThinkBlockStart);
const after = this.fullResponse.substring(closeIdx + "</think>".length);
this.fullResponse = (before + after).trimStart();
this.excludedThinkBlockStart = -1;
} else {
// Still streaming thinking -- truncate to the safe prefix
this.fullResponse = this.fullResponse.substring(0, this.excludedThinkBlockStart);
}
return;
}
// Not inside a block -- check for a new one
const openIdx = this.fullResponse.indexOf("<think>");
if (openIdx !== -1) {
this.excludedThinkBlockStart = openIdx;
const closeIdx = this.fullResponse.indexOf("</think>", openIdx);
if (closeIdx !== -1) {
// Complete block in one pass
const before = this.fullResponse.substring(0, openIdx);
const after = this.fullResponse.substring(closeIdx + "</think>".length);
this.fullResponse = (before + after).trimStart();
this.excludedThinkBlockStart = -1;
} else {
// Incomplete -- truncate
this.fullResponse = this.fullResponse.substring(0, openIdx);
}
return;
}
// Handle malformed: only </think> without <think> (e.g., some chat templates)
const closeIdx = this.fullResponse.indexOf("</think>");
if (closeIdx !== -1) {
this.fullResponse = this.fullResponse.substring(closeIdx + "</think>".length).trimStart();
}
}
private handleClaudeChunk(content: Array<{ type?: string; text?: string; thinking?: string }>) {
let textContent = "";
let hasThinkingContent = false;
for (const item of content) {
switch (item.type) {
case "text":
textContent += item.text;
break;
case "thinking":
hasThinkingContent = true;
// Skip thinking content if excludeThinking is enabled
if (this.excludeThinking) {
break;
}
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
@ -31,23 +143,35 @@ export class ThinkBlockStreamer {
this.fullResponse += item.thinking;
}
this.updateCurrentAiMessage(this.fullResponse);
return true; // Indicate we handled a thinking chunk
break;
}
}
if (textContent) {
this.fullResponse += textContent;
// Close think block before adding text content
if (textContent && this.hasOpenThinkBlock) {
this.fullResponse += "</think>";
this.hasOpenThinkBlock = false;
}
return false; // No thinking chunk handled
if (textContent) {
this.fullResponse += stripSpecialTokens(textContent);
}
return hasThinkingContent;
}
private handleDeepseekChunk(chunk: any) {
private handleDeepseekChunk(chunk: {
content?: string;
additional_kwargs?: { reasoning_content?: string };
}) {
// Handle standard string content
if (typeof chunk.content === "string") {
this.fullResponse += chunk.content;
this.fullResponse += stripSpecialTokens(chunk.content);
}
// Handle deepseek reasoning/thinking content
if (chunk.additional_kwargs?.reasoning_content) {
// Skip thinking content if excludeThinking is enabled
if (this.excludeThinking) {
return true; // Indicate we handled (but skipped) a thinking chunk
}
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
@ -61,64 +185,215 @@ export class ThinkBlockStreamer {
return false; // No thinking chunk handled
}
processChunk(chunk: any) {
// If we've already decided to truncate, don't process more chunks
if (this.shouldTruncate) {
return;
/**
* Handle OpenRouter reasoning/thinking content
*
* OpenRouter exposes reasoning via two channels:
* - delta.reasoning (streaming, token-by-token)
* - reasoning_details (cumulative transcript array)
*
* STRATEGY: We use ONLY delta.reasoning for thinking content.
*
* Why delta-only?
* - Provides minimal latency (streaming as tokens arrive)
* - No duplication issues (single source of truth)
* - No complex cumulative bookkeeping needed
*
* Trade-offs:
* - Models that only populate reasoning_details (without delta.reasoning) won't show thinking
* - This is acceptable for now as most models use delta.reasoning for streaming
*/
private handleOpenRouterChunk(chunk: {
content?: string;
additional_kwargs?: {
delta?: { reasoning?: string };
reasoning_details?: unknown[];
};
}) {
// Only process delta.reasoning (streaming), ignore reasoning_details entirely
if (chunk.additional_kwargs?.delta?.reasoning) {
// Skip thinking content if excludeThinking is enabled
if (this.excludeThinking) {
return true;
}
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
}
this.fullResponse += chunk.additional_kwargs.delta.reasoning;
return true; // Handled thinking
}
let handledThinking = false;
// Handle Claude 3.7 array-based content
if (Array.isArray(chunk.content)) {
handledThinking = this.handleClaude37Chunk(chunk.content);
} else {
// Handle deepseek format
handledThinking = this.handleDeepseekChunk(chunk);
}
// Close think block if we have one open and didn't handle thinking content
if (this.hasOpenThinkBlock && !handledThinking) {
// Close think block before adding regular content
if (typeof chunk.content === "string" && chunk.content && this.hasOpenThinkBlock) {
this.fullResponse += "</think>";
this.hasOpenThinkBlock = false;
}
// Check if we should truncate streaming based on model adapter
if (this.modelAdapter?.shouldTruncateStreaming?.(this.fullResponse)) {
this.shouldTruncate = true;
// Find the last complete tool call to truncate cleanly
this.fullResponse = this.truncateToLastCompleteToolCall(this.fullResponse);
// Handle standard string content (this is the actual response, not thinking)
if (typeof chunk.content === "string" && chunk.content) {
this.fullResponse += chunk.content;
}
return false; // No thinking handled
}
/**
* Accumulate native tool call chunks during streaming.
* LangChain providers send tool_call_chunks with incremental data.
*/
private handleToolCallChunks(chunk: {
tool_call_chunks?: Array<{
index?: number;
id?: string;
name?: string;
args?: string;
}>;
}) {
// Check for tool_call_chunks in the chunk (LangChain streaming format)
const toolCallChunks = chunk.tool_call_chunks;
if (!toolCallChunks || !Array.isArray(toolCallChunks)) {
return;
}
for (const tc of toolCallChunks) {
const idx: number = (tc.index as number) ?? 0;
const existing = this.toolCallChunks.get(idx) || { name: "", args: "" };
// Accumulate data from chunk
if (tc.id) existing.id = tc.id;
if (tc.name) existing.name += tc.name;
if (tc.args) existing.args += tc.args;
this.toolCallChunks.set(idx, existing);
}
}
processChunk(chunk: {
response_metadata?: Record<string, unknown>;
usage_metadata?: { input_tokens?: number; output_tokens?: number; total_tokens?: number };
tool_call_chunks?: Array<{ index?: number; id?: string; name?: string; args?: string }>;
content?: string | Array<{ type?: string; text?: string; thinking?: string }>;
additional_kwargs?: {
reasoning_content?: string;
delta?: { reasoning?: string };
reasoning_details?: unknown[];
};
}) {
// Detect truncation using multi-provider detector
const truncationResult = detectTruncation(chunk);
if (truncationResult.wasTruncated) {
this.wasTruncated = true;
}
// Extract token usage if available
const usage = extractTokenUsage(chunk);
if (usage) {
this.tokenUsage = usage;
}
// Handle native tool call chunks (LangChain streaming)
this.handleToolCallChunks(chunk);
// Determine if this chunk will handle thinking content
// Note: For OpenRouter, we process only delta.reasoning, but we still need to recognize
// reasoning_details as a thinking chunk to prevent premature think block closure
const isThinkingChunk =
Array.isArray(chunk.content) ||
chunk.additional_kwargs?.delta?.reasoning ||
(chunk.additional_kwargs?.reasoning_details &&
Array.isArray(chunk.additional_kwargs.reasoning_details) &&
chunk.additional_kwargs.reasoning_details.length > 0) ||
chunk.additional_kwargs?.reasoning_content; // Deepseek format
// Close think block BEFORE processing non-thinking content
if (this.hasOpenThinkBlock && !isThinkingChunk) {
this.fullResponse += "</think>";
this.hasOpenThinkBlock = false;
}
// Now process the chunk
// Route based on the actual chunk format
if (Array.isArray(chunk.content)) {
// Claude format with content array
// chunk.content is Array<...> in this branch (checked by Array.isArray guard above)
this.handleClaudeChunk(chunk.content);
} else if (chunk.additional_kwargs?.reasoning_content) {
// Deepseek format with reasoning_content
this.handleDeepseekChunk(chunk as Parameters<typeof this.handleDeepseekChunk>[0]);
} else if (isThinkingChunk) {
// OpenRouter format with delta.reasoning or reasoning_details
this.handleOpenRouterChunk(chunk as Parameters<typeof this.handleOpenRouterChunk>[0]);
} else {
// Default case: regular content or other formats
this.handleDeepseekChunk(chunk as Parameters<typeof this.handleDeepseekChunk>[0]);
}
// Handle text-level think tags (e.g., from nvidia/nemotron models)
this.handleTextLevelThinkTags();
this.updateCurrentAiMessage(this.fullResponse);
}
private truncateToLastCompleteToolCall(response: string): string {
// Find the last complete </use_tool> tag
const lastCompleteToolEnd = response.lastIndexOf("</use_tool>");
if (lastCompleteToolEnd === -1) {
// No complete tool calls found, return original response
return response;
}
// Truncate to after the last complete tool call
const truncated = response.substring(0, lastCompleteToolEnd + "</use_tool>".length);
// Use model adapter to sanitize if available
if (this.modelAdapter?.sanitizeResponse) {
return this.modelAdapter.sanitizeResponse(truncated, 1);
}
return truncated;
processErrorChunk(errorMessage: string) {
this.errorResponse = formatErrorChunk(errorMessage);
}
close() {
/**
* Get the accumulated tool calls from streaming chunks.
* Call this after streaming is complete to get all tool calls.
*/
getToolCalls(): NativeToolCall[] {
// If we have pre-accumulated tool calls (from non-streaming), return those
if (this.accumulatedToolCalls.length > 0) {
return this.accumulatedToolCalls;
}
// Otherwise build from streaming chunks
return buildToolCallsFromChunks(this.toolCallChunks);
}
/**
* Check if there are any tool calls accumulated
*/
hasToolCalls(): boolean {
return this.toolCallChunks.size > 0 || this.accumulatedToolCalls.length > 0;
}
/**
* Set tool calls directly (for non-streaming responses)
*/
setToolCalls(toolCalls: NativeToolCall[]) {
this.accumulatedToolCalls = toolCalls;
}
/**
* Build an AIMessage with the accumulated content and tool calls.
* Use this to add the complete response to conversation history.
*/
buildAIMessage(): AIMessage {
const toolCalls = this.getToolCalls();
return createAIMessageWithToolCalls(this.fullResponse, toolCalls);
}
close(): StreamingResult {
// Make sure to close any open think block at the end
if (this.hasOpenThinkBlock) {
this.fullResponse += "</think>";
this.updateCurrentAiMessage(this.fullResponse);
}
return this.fullResponse;
// Final check for text-level think tags (in case stream ended before </think> was seen)
this.handleTextLevelThinkTags();
if (this.errorResponse) {
this.fullResponse += this.errorResponse;
}
this.updateCurrentAiMessage(this.fullResponse);
return {
content: this.fullResponse,
wasTruncated: this.wasTruncated,
tokenUsage: this.tokenUsage,
};
}
}

View file

@ -2,6 +2,8 @@ import {
processRawChatHistory,
addChatHistoryToMessages,
processedMessagesToTextOnly,
extractConversationTurns,
estimateToolOutputSize,
} from "./chatHistoryUtils";
describe("chatHistoryUtils", () => {
@ -146,7 +148,7 @@ describe("chatHistoryUtils", () => {
},
];
const messages: any[] = [];
const messages: { role: string; content: string | unknown[] }[] = [];
addChatHistoryToMessages(rawHistory, messages);
expect(messages).toEqual([
@ -242,4 +244,214 @@ describe("chatHistoryUtils", () => {
expect(result).toEqual([{ role: "user", content: "[Image content]" }]);
});
});
describe("extractConversationTurns", () => {
it("should extract complete turn pairs from even-length history", () => {
const history = [
{ role: "user" as const, content: "Hello" },
{ role: "assistant" as const, content: "Hi there!" },
{ role: "user" as const, content: "How are you?" },
{ role: "assistant" as const, content: "I am doing well!" },
];
const result = extractConversationTurns(history);
expect(result.turns).toEqual([
{ user: "Hello", assistant: "Hi there!" },
{ user: "How are you?", assistant: "I am doing well!" },
]);
expect(result.trailingUserMessage).toBeNull();
});
it("should detect trailing unpaired user message", () => {
const history = [
{ role: "user" as const, content: "Hello" },
{ role: "assistant" as const, content: "Hi there!" },
{ role: "user" as const, content: "What about this?" },
];
const result = extractConversationTurns(history);
expect(result.turns).toEqual([{ user: "Hello", assistant: "Hi there!" }]);
expect(result.trailingUserMessage).toBe("What about this?");
});
it("should handle single user message (no complete turns)", () => {
const history = [{ role: "user" as const, content: "Hello" }];
const result = extractConversationTurns(history);
expect(result.turns).toEqual([]);
expect(result.trailingUserMessage).toBe("Hello");
});
it("should handle empty history", () => {
const result = extractConversationTurns([]);
expect(result.turns).toEqual([]);
expect(result.trailingUserMessage).toBeNull();
});
it("should handle multimodal content by extracting text", () => {
const multimodalContent = [
{ type: "text", text: "What is this?" },
{ type: "image_url", image_url: { url: "data:image/jpeg;base64,..." } },
];
const history = [
{ role: "user" as const, content: multimodalContent },
{ role: "assistant" as const, content: "This is a cat." },
{ role: "user" as const, content: "Tell me more" },
];
const result = extractConversationTurns(history);
// Should extract text content, not stringify (which would include huge base64 data)
expect(result.turns).toEqual([{ user: "What is this?", assistant: "This is a cat." }]);
expect(result.trailingUserMessage).toBe("Tell me more");
});
it("should handle trailing user message with multimodal content", () => {
const multimodalContent = [{ type: "text", text: "Look at this" }];
const history = [
{ role: "user" as const, content: "Hello" },
{ role: "assistant" as const, content: "Hi!" },
{ role: "user" as const, content: multimodalContent },
];
const result = extractConversationTurns(history);
expect(result.turns).toEqual([{ user: "Hello", assistant: "Hi!" }]);
// Should extract text content, not stringify
expect(result.trailingUserMessage).toBe("Look at this");
});
it("should handle assistant-first history (e.g., BufferWindowMemory slice)", () => {
// When BufferWindowMemory slices mid-conversation, history may start with assistant
const history = [
{ role: "assistant" as const, content: "Starting message" }, // assistant first (sliced)
{ role: "user" as const, content: "Hello" },
{ role: "assistant" as const, content: "Hi!" },
];
const result = extractConversationTurns(history);
// Should skip the orphaned assistant message and extract the user-assistant pair
expect(result.turns).toEqual([{ user: "Hello", assistant: "Hi!" }]);
expect(result.trailingUserMessage).toBeNull();
});
it("should handle multiple orphaned assistant messages at start", () => {
const history = [
{ role: "assistant" as const, content: "Orphan 1" },
{ role: "assistant" as const, content: "Orphan 2" },
{ role: "user" as const, content: "Hello" },
{ role: "assistant" as const, content: "Hi!" },
{ role: "user" as const, content: "Follow up" },
];
const result = extractConversationTurns(history);
// Should skip orphaned assistant messages, extract one turn, detect trailing user
expect(result.turns).toEqual([{ user: "Hello", assistant: "Hi!" }]);
expect(result.trailingUserMessage).toBe("Follow up");
});
it("should handle consecutive user messages (only last before assistant forms pair)", () => {
const history = [
{ role: "user" as const, content: "First attempt" },
{ role: "user" as const, content: "Let me rephrase" },
{ role: "assistant" as const, content: "I understand now" },
];
const result = extractConversationTurns(history);
// First user message has no following assistant, second does
expect(result.turns).toEqual([{ user: "Let me rephrase", assistant: "I understand now" }]);
expect(result.trailingUserMessage).toBeNull();
});
});
describe("estimateToolOutputSize", () => {
it("should return 0 for empty tool outputs", () => {
const result = estimateToolOutputSize([]);
expect(result).toBe(0);
});
it("should calculate size for single tool output", () => {
const toolOutputs = [{ tool: "search", output: "result data" }];
const result = estimateToolOutputSize(toolOutputs);
// "# Additional context:\n\n" = 23 chars (21 + 2 newlines)
// "<search>\n" = 9 chars
// "result data" = 11 chars
// "\n</search>" = 10 chars
// Total: 23 + 9 + 11 + 10 = 53
expect(result).toBe(53);
});
it("should calculate size for multiple tool outputs with separators", () => {
const toolOutputs = [
{ tool: "search", output: "abc" },
{ tool: "web", output: "xyz" },
];
const result = estimateToolOutputSize(toolOutputs);
// "# Additional context:\n\n" = 23 chars
// "<search>\nabc\n</search>" = 9 + 3 + 10 = 22 chars
// "\n\n" separator = 2 chars
// "<web>\nxyz\n</web>" = 6 + 3 + 7 = 16 chars
// Total: 23 + 22 + 2 + 16 = 63
expect(result).toBe(63);
});
it("should stringify object outputs", () => {
const toolOutputs = [{ tool: "api", output: { key: "value" } }];
const result = estimateToolOutputSize(toolOutputs);
// JSON.stringify({ key: "value" }) = '{"key":"value"}' = 15 chars
// "# Additional context:\n\n" = 23 chars
// "<api>\n" = 6 chars
// jsonContent = 15 chars
// "\n</api>" = 7 chars
// Total: 23 + 6 + 15 + 7 = 51
expect(result).toBe(51);
});
it("should handle tool outputs with long content", () => {
const longContent = "x".repeat(10000);
const toolOutputs = [{ tool: "data", output: longContent }];
const result = estimateToolOutputSize(toolOutputs);
// "# Additional context:\n\n" = 23 chars
// "<data>\n" = 7 chars
// longContent = 10000 chars
// "\n</data>" = 8 chars
// Total: 23 + 7 + 10000 + 8 = 10038
expect(result).toBe(10038);
});
it("should match actual formatted output size", () => {
// This test verifies the estimate matches the actual format used
const toolOutputs = [
{ tool: "localSearch", output: "Found 5 results" },
{ tool: "webSearch", output: "Web results here" },
];
const estimated = estimateToolOutputSize(toolOutputs);
// Manually build what formatAllToolOutputs would produce
const formatted =
"# Additional context:\n\n" +
"<localSearch>\nFound 5 results\n</localSearch>\n\n" +
"<webSearch>\nWeb results here\n</webSearch>";
expect(estimated).toBe(formatted.length);
});
});
});

View file

@ -2,9 +2,11 @@
* Utility functions for safely processing chat history from LangChain memory
*/
import { logInfo } from "@/logger";
export interface ProcessedMessage {
role: "user" | "assistant";
content: any; // string or MessageContent[]
content: string | unknown[]; // string or MessageContent[]
}
/**
@ -14,28 +16,29 @@ export interface ProcessedMessage {
* @param rawHistory Array of messages from memory.loadMemoryVariables()
* @returns Array of processed messages safe for LLM consumption
*/
export function processRawChatHistory(rawHistory: any[]): ProcessedMessage[] {
export function processRawChatHistory(rawHistory: unknown[]): ProcessedMessage[] {
const messages: ProcessedMessage[] = [];
for (const message of rawHistory) {
if (!message) continue;
const msg = message as Record<string, unknown>;
// Check if this is a BaseMessage with _getType method
if (typeof message._getType === "function") {
const messageType = message._getType();
if (typeof msg._getType === "function") {
const messageType = (msg._getType as () => string)();
// Only process human and AI messages
if (messageType === "human") {
messages.push({ role: "user", content: message.content });
messages.push({ role: "user", content: msg.content as string | unknown[] });
} else if (messageType === "ai") {
messages.push({ role: "assistant", content: message.content });
messages.push({ role: "assistant", content: msg.content as string | unknown[] });
}
// Skip system messages and unknown types
} else if (message.content !== undefined) {
} else if (msg.content !== undefined) {
// Fallback for other message formats - try to infer role
const role = inferMessageRole(message);
const role = inferMessageRole(msg);
if (role) {
messages.push({ role, content: message.content });
messages.push({ role, content: msg.content as string | unknown[] });
}
}
}
@ -47,7 +50,7 @@ export function processRawChatHistory(rawHistory: any[]): ProcessedMessage[] {
* Try to infer the role from various message format properties
* @returns 'user' | 'assistant' | null
*/
function inferMessageRole(message: any): "user" | "assistant" | null {
function inferMessageRole(message: Record<string, unknown>): "user" | "assistant" | null {
// Check various properties that might indicate the role
if (message.role === "human" || message.role === "user" || message.sender === "user") {
return "user";
@ -66,7 +69,10 @@ function inferMessageRole(message: any): "user" | "assistant" | null {
* @param rawHistory Raw history from memory
* @param messages Target messages array to add to
*/
export function addChatHistoryToMessages(rawHistory: any[], messages: any[]): void {
export function addChatHistoryToMessages(
rawHistory: unknown[],
messages: Array<{ role: string; content: string | unknown[] }>
): void {
const processedHistory = processRawChatHistory(rawHistory);
for (const msg of processedHistory) {
messages.push({ role: msg.role, content: msg.content });
@ -78,6 +84,27 @@ export interface ChatHistoryEntry {
content: string;
}
/**
* Extract text content from potentially multimodal message content.
* Replaces non-text content (images) with placeholder.
*/
function extractTextContent(content: string | unknown[]): string {
if (typeof content === "string") {
return content;
} else if (Array.isArray(content)) {
// Extract text from multimodal content, skip image_url payloads
const textParts: string = content
.filter(
(item): item is { type: string; text?: string } =>
typeof item === "object" && item !== null && (item as { type?: unknown }).type === "text"
)
.map((item): string => item.text || "")
.join(" ");
return textParts || "[Image content]";
}
return String(content || "");
}
/**
* Convert processed messages to text-only format for question condensing
* This extracts just the text content from potentially multimodal messages
@ -88,25 +115,162 @@ export interface ChatHistoryEntry {
export function processedMessagesToTextOnly(
processedMessages: ProcessedMessage[]
): ChatHistoryEntry[] {
return processedMessages.map((msg) => {
let textContent: string;
if (typeof msg.content === "string") {
textContent = msg.content;
} else if (Array.isArray(msg.content)) {
// Extract text from multimodal content
const textParts = msg.content
.filter((item: any) => item.type === "text")
.map((item: any) => item.text || "")
.join(" ");
textContent = textParts || "[Image content]";
} else {
textContent = String(msg.content || "");
}
return {
role: msg.role,
content: textContent,
};
});
return processedMessages.map((msg) => ({
role: msg.role,
content: extractTextContent(msg.content),
}));
}
/**
* Tool output structure for size estimation
*/
export interface ToolOutput {
tool: string;
output: string | object;
}
/**
* Estimates the size of formatted tool outputs without actually formatting them.
* Used to include tool output size in compaction threshold calculations.
*
* Tool outputs are formatted as:
* ```
* # Additional context:
*
* <toolName>
* {content}
* </toolName>
* ```
*
* @param toolOutputs - Array of tool outputs with tool name and output content
* @returns Estimated character count of formatted tool outputs
*/
export function estimateToolOutputSize(toolOutputs: ToolOutput[]): number {
if (toolOutputs.length === 0) return 0;
// Estimate: "# Additional context:\n\n" prefix
let size = "# Additional context:\n\n".length;
for (let i = 0; i < toolOutputs.length; i++) {
const output = toolOutputs[i];
const content =
typeof output.output === "string" ? output.output : JSON.stringify(output.output);
// Format: <tool>\n{content}\n</tool>
size += `<${output.tool}>\n`.length + content.length + `\n</${output.tool}>`.length;
// Join separator: \n\n between outputs
if (i < toolOutputs.length - 1) {
size += 2;
}
}
return size;
}
/**
* Result of extracting conversation turns from processed history.
*/
export interface ExtractedTurns {
/** Complete user-assistant turn pairs */
turns: Array<{ user: string; assistant: string }>;
/** Trailing user message without assistant response (e.g., after aborted generation) */
trailingUserMessage: string | null;
}
/**
* Extract conversation turns from processed chat history.
* Handles both complete turn pairs and trailing unpaired user messages.
* Scans sequentially for userassistant pairs to handle histories that may
* start with an assistant message (e.g., when BufferWindowMemory slices mid-conversation).
*
* @param processedHistory - Processed chat history messages
* @returns Object with turns array and optional trailing user message
*/
export function extractConversationTurns(processedHistory: ProcessedMessage[]): ExtractedTurns {
const turns: Array<{ user: string; assistant: string }> = [];
let trailingUserMessage: string | null = null;
// Scan sequentially for user→assistant pairs
let i = 0;
while (i < processedHistory.length) {
const msg = processedHistory[i];
if (msg?.role === "user") {
// Found a user message, look for the following assistant message
const nextMsg = processedHistory[i + 1];
if (nextMsg?.role === "assistant") {
turns.push({
user: extractTextContent(msg.content),
assistant: extractTextContent(nextMsg.content),
});
i += 2; // Skip both messages
} else {
// User message without following assistant (trailing or orphaned)
// If this is the last message, it's a trailing user message
if (i === processedHistory.length - 1) {
trailingUserMessage = extractTextContent(msg.content);
}
i += 1;
}
} else {
// Skip assistant messages that aren't paired with a preceding user message
// (e.g., at the start of a window slice)
i += 1;
}
}
return { turns, trailingUserMessage };
}
/**
* Load chat history from memory and add to messages array.
* This is the single entry point for all chain runners to use.
*
* Note: Chat history is already compacted at save time (in MemoryManager.saveContext)
* so tool results (localSearch, readNote, etc.) are stored as compact summaries.
*
* @param memory - LangChain memory instance
* @param messages - Target messages array (system message should already be added)
* @returns The processed history that was added
*/
export async function loadAndAddChatHistory(
memory: {
loadMemoryVariables: (vars: Record<string, unknown>) => Promise<{ history?: unknown[] }>;
},
messages: Array<{ role: string; content: string | unknown[] }>
): Promise<ProcessedMessage[]> {
const memoryVariables = await memory.loadMemoryVariables({});
const rawHistory = memoryVariables.history || [];
const processedHistory = rawHistory.length ? processRawChatHistory(rawHistory) : [];
// Add history messages directly (already compacted at save time)
for (const msg of processedHistory) {
messages.push({ role: msg.role, content: msg.content });
}
// Log per-layer token estimates when payload is large (>3M chars ≈ 750k tokens).
// Only use string .length (O(1)) — skip non-string content entirely.
let systemChars = 0;
for (const m of messages) {
if (typeof m.content === "string" && m.role === "system") {
systemChars += m.content.length;
}
}
let historyChars = 0;
for (const m of processedHistory) {
if (typeof m.content === "string") {
historyChars += m.content.length;
}
}
const totalChars = systemChars + historyChars;
// ~500k tokens — log when approaching context window limits to help diagnose overflow reports
if (totalChars > 2_000_000) {
logInfo("[Token Budget] Large payload detected (excluding user message):", {
"L1+L2 (system)": `${Math.round(systemChars / 4000)}k tokens`,
"L4 (history)": `${Math.round(historyChars / 4000)}k tokens (${processedHistory.length} msgs)`,
total: `${Math.round(totalChars / 4000)}k tokens`,
});
}
return processedHistory;
}

View file

@ -0,0 +1,113 @@
import {
buildLocalSearchInnerContent,
ensureCiCOrderingWithQuestion,
injectGuidanceBeforeUserQuery,
renderCiCMessage,
wrapLocalSearchPayload,
} from "./cicPromptUtils";
describe("cicPromptUtils", () => {
describe("buildLocalSearchInnerContent", () => {
it("orders intro text before documents and trims whitespace", () => {
const intro = "\nIntro block\n";
const documents = "\n<document>Doc</document>\n";
const inner = buildLocalSearchInnerContent(intro, documents);
expect(inner).toBe("Intro block\n\n<document>Doc</document>");
});
it("returns empty string when both inputs are blank", () => {
expect(buildLocalSearchInnerContent("", "")).toBe("");
});
});
describe("wrapLocalSearchPayload", () => {
it("wraps content with localSearch tag and preserves time range", () => {
const content = "<guidance>Rules</guidance>\n\n<document>Doc</document>";
const wrapped = wrapLocalSearchPayload(content, "last week");
expect(wrapped).toBe(
'<localSearch timeRange="last week">\n<guidance>Rules</guidance>\n\n<document>Doc</document>\n</localSearch>'
);
});
it("omits payload whitespace when content is empty", () => {
expect(wrapLocalSearchPayload("", "")).toBe("<localSearch></localSearch>");
});
});
describe("renderCiCMessage", () => {
it("places context before question", () => {
const combined = renderCiCMessage("context block", "What?");
expect(combined).toBe("context block\n\nWhat?");
});
it("returns the original question when context is blank", () => {
expect(renderCiCMessage(" \n ", "What?")).toBe("What?");
});
});
describe("ensureCiCOrderingWithQuestion", () => {
it("appends the trimmed question with [User query]: label after the payload when missing", () => {
const payload = "<localSearch>\n<context/>\n</localSearch>";
const question = " What did I do last week? ";
const result = ensureCiCOrderingWithQuestion(payload, question);
// Should add "[User query]:" label (same format as LayerToMessagesConverter)
expect(result).toBe(renderCiCMessage(payload, "[User query]:\nWhat did I do last week?"));
});
it("returns payload unchanged when question already included", () => {
const question = "What did I do last week?";
const payload = renderCiCMessage("<localSearch />", `[User query]:\n${question}`);
expect(ensureCiCOrderingWithQuestion(payload, question)).toBe(payload);
});
it("returns payload unchanged when question is blank", () => {
const payload = "<localSearch>\n<context/>\n</localSearch>";
expect(ensureCiCOrderingWithQuestion(payload, " ")).toBe(payload);
});
});
describe("injectGuidanceBeforeUserQuery", () => {
const guidance = "<guidance>\nRules\n</guidance>";
it("places guidance before user query label when present", () => {
const payload = "# Additional context:\n\n<context>\n</context>\n\n[User query]:\nWhat?";
const result = injectGuidanceBeforeUserQuery(payload, guidance);
expect(result).toBe(
"# Additional context:\n\n<context>\n</context>\n\n<guidance>\nRules\n</guidance>\n\n[User query]:\nWhat?"
);
});
it("appends guidance when user query label missing", () => {
const payload = "<context>\n</context>";
const result = injectGuidanceBeforeUserQuery(payload, guidance);
expect(result).toBe("<context>\n</context>\n\n<guidance>\nRules\n</guidance>");
});
it("leaves payload unchanged when guidance empty", () => {
const payload = "<context>\n</context>";
expect(injectGuidanceBeforeUserQuery(payload, "")).toBe(payload);
expect(injectGuidanceBeforeUserQuery(payload, null)).toBe(payload);
expect(injectGuidanceBeforeUserQuery(payload, undefined)).toBe(payload);
});
it("handles payloads with trailing whitespace before label", () => {
const payload = "# Additional context:\n\n<context>\n</context>\n \n\n[User query]:\nWhat?";
const result = injectGuidanceBeforeUserQuery(payload, guidance);
expect(result).toBe(
"# Additional context:\n\n<context>\n</context>\n\n<guidance>\nRules\n</guidance>\n\n[User query]:\nWhat?"
);
});
});
});

View file

@ -0,0 +1,97 @@
/**
* Helper utilities for assembling Corpus-in-Context (CiC) ordered tool payloads.
*/
/**
* Builds the ordered inner payload for a localSearch result, placing optional lead-in
* instructions ahead of the serialized documents.
* @param introText Context-setting instructions to show before the documents.
* @param formattedContent Serialized documents selected for inclusion.
* @returns Combined payload string with minimal whitespace.
*/
export function buildLocalSearchInnerContent(introText: string, formattedContent: string): string {
const sections = [introText, formattedContent]
.map((section) => section?.trim())
.filter((section): section is string => Boolean(section));
return sections.join("\n\n");
}
/**
* Wraps localSearch payload content in XML, preserving optional time range metadata.
* @param innerContent Ordered combination of guidance and documents.
* @param timeExpression Optional natural-language time range expression.
* @returns XML-wrapped localSearch payload ready for LLM consumption.
*/
export function wrapLocalSearchPayload(innerContent: string, timeExpression: string): string {
const payload = innerContent ? `\n${innerContent}\n` : "";
const timeAttribute = timeExpression ? ` timeRange="${timeExpression}"` : "";
return `<localSearch${timeAttribute}>${payload}</localSearch>`;
}
/**
* Produces a CiC-aligned prompt by placing context first and the user question last.
* @param contextSection Prepared instruction/context block.
* @param userQuestion Original user message.
* @returns String formatted according to CiC ordering.
*/
export function renderCiCMessage(contextSection: string, userQuestion: string): string {
const contextBlock = contextSection.trim();
if (!contextBlock) {
return userQuestion;
}
return `${contextBlock}\n\n${userQuestion}`;
}
/**
* Ensure a CiC payload appends the user's question with a "[User query]:" label, avoiding duplicates when the question already exists.
* Uses the same label format as LayerToMessagesConverter for consistency across chains.
* Used in AutonomousAgent to clearly separate tool results from the original user query.
* @param localSearchPayload XML-wrapped local search payload.
* @param originalUserQuestion The original user question to append.
* @returns CiC ordered payload with the labeled question appended when missing.
*/
export function ensureCiCOrderingWithQuestion(
localSearchPayload: string,
originalUserQuestion: string
): string {
const trimmedQuestion = originalUserQuestion.trim();
if (!trimmedQuestion) {
return localSearchPayload;
}
if (localSearchPayload.includes(trimmedQuestion)) {
return localSearchPayload;
}
// Use same label format as LayerToMessagesConverter for consistency
return renderCiCMessage(localSearchPayload, `[User query]:\n${trimmedQuestion}`);
}
/**
* Inserts citation guidance immediately before the user query label, keeping tool context intact.
* @param payload Serialized CiC payload containing tool outputs and user query.
* @param guidance Guidance block (typically <guidance>...</guidance>) to insert.
* @returns Payload with guidance positioned before `[User query]:` or appended when label missing.
*/
export function injectGuidanceBeforeUserQuery(payload: string, guidance?: string | null): string {
const trimmedGuidance = guidance?.trim();
if (!trimmedGuidance) {
return payload;
}
const userQueryLabel = "[User query]:";
const labelIndex = payload.lastIndexOf(userQueryLabel);
if (labelIndex === -1) {
const trimmedPayload = payload.trimEnd();
const joiner = trimmedPayload.length > 0 ? "\n\n" : "";
return `${trimmedPayload}${joiner}${trimmedGuidance}`;
}
const prefix = payload.slice(0, labelIndex).trimEnd();
const suffix = payload.slice(labelIndex).trimStart();
return `${prefix}\n\n${trimmedGuidance}\n\n${suffix}`;
}

Some files were not shown because too many files have changed in this diff Show more