Compare commits

..

388 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
Logan Yang
731a8e819e
3.0.3 (#1842) 2025-09-22 20:39:48 -07:00
Logan Yang
97f3b5d2c4
Update model setting buttons (#1843)
* Add grok 4 fast openrouter

* Update model setting buttons
2025-09-22 20:13:03 -07:00
Logan Yang
7bed022d8a
fix: adjust vertical positioning in ModelTable component (#1840) 2025-09-22 19:34:40 -07:00
Emt-lin
86b7fcab88
feat: add mobile-responsive components for settings. (#1813)
* feat: add mobile-responsive components for settings.
* fix: Restrict HelpTooltip components to only affect mobile devices.
* fix: fix the args.diff case test.
2025-09-22 18:06:54 -07:00
Logan Yang
047d42c5a7
Update sources styling (#1838) 2025-09-22 17:19:17 -07:00
Logan Yang
feeea21127
Drop user and bot icons to save space and add shade to user message (#1837) 2025-09-22 14:22:52 -07:00
Logan Yang
09124e19e5
Add OpenRouter GPT-4.1 models to projects mode (#1832) 2025-09-22 00:39:58 -07:00
Logan Yang
dd7e9e185f
Refactor active note inclusion and index event handling to respect setting (#1831)
* Refactor active note inclusion logic

* Refactor IndexEventHandler to manage event listeners based on semantic search settings

- Introduced `shouldHandleEvents` method to determine if indexing-related events should be processed.
- Replaced `initializeEventListeners` with `syncEventListeners` to conditionally register listeners based on settings.
- Added `teardownEventListeners` method to clean up listeners and reset state when not needed.
- Subscribed to settings changes to dynamically manage event listeners.
2025-09-22 00:02:20 -07:00
Logan Yang
8076a0e171
Refactor constants and settings to enhance folder management (#1830)
* Refactor constants and settings to enhance folder management

- Introduced `COPILOT_FOLDER_ROOT` constant to streamline folder path definitions.
- Updated `DEFAULT_CHAT_HISTORY_FOLDER` and `DEFAULT_CUSTOM_PROMPTS_FOLDER` to use the new root constant for improved organization.
- Added `DEFAULT_QA_EXCLUSIONS_SETTING` to ensure consistent exclusion of the Copilot folder in QA settings.
- Implemented `sanitizeQaExclusions` function to normalize QA exclusion patterns, ensuring the Copilot folder is properly excluded from settings.

* Update log file management to reduce in-memory buffer size

- Decreased the in-memory ring buffer from 1000 to 500 entries to optimize memory usage.
- Adjusted documentation to reflect the new buffer size for clarity.
- Updated log truncation logic in BaseChainRunner to use a constant for maximum log length, enhancing maintainability.

* Add unit tests for sanitizeQaExclusions function

- Introduced a new test file `model.test.ts` to validate the behavior of the `sanitizeQaExclusions` function.
- Added tests to ensure proper handling of non-string values, distinct slash-only patterns, and normalization of trailing slashes.
- Enhanced the `sanitizeQaExclusions` function in `model.ts` to improve key handling and normalization logic for QA exclusion patterns.
2025-09-21 20:56:52 -07:00
Logan Yang
92d6f98af2
Implement inline citation (#1821)
* Implement inline citation for vault search for both vault QA and Plus chains

refactor: enhance citation utilities and improve source handling in chain runners

- Introduced new utility functions for citation management, including sanitization and formatting of source catalogs.
- Updated CopilotPlusChainRunner and VaultQAChainRunner to utilize the new citation utilities for improved citation handling.
- Enhanced ChatSingleMessage component to consolidate duplicate sources and ensure accurate citation numbering.
- Added comprehensive tests for citation utilities to ensure functionality and edge case handling.

refactor: dedupe chunks for unique titles in search tool sources

refactor: enhance citation handling in CopilotPlus and VaultQA chain runners

- Introduced a sanitization function to remove pre-existing citation markers from document content, preventing number leakage.
- Updated the formatting of retrieved documents in VaultQA to utilize the new sanitization function for cleaner output.
- Improved footnote renumbering logic in ChatSingleMessage to prioritize first-mention order, ensuring accurate citation mapping.

refactor: streamline source citation handling in ChatSingleMessage component and enhance web search tool instructions

- Simplified the processing of source links in ChatSingleMessage to use a more efficient mapping approach.
- Updated the web search tool's instruction to clarify the use of footnote-style citations, ensuring compatibility with Markdown formatting.

* feat: add inline citation setting toggle and refactor

- Introduced a new setting to enable inline citations in AI-generated responses, allowing for footnote-style references within the text.
- Updated citation utilities to manage fallback sources and improve citation instructions based on user settings.
- Enhanced CopilotPlusChainRunner and VaultQAChainRunner to utilize the new inline citation feature, ensuring accurate source representation.
- Modified ChatSingleMessage component to process inline citations based on the new setting, improving the display of sources in chat messages.
- Added tests for new citation functionalities to ensure reliability and correctness.

* feat: enhance citation utilities and add tests for normalization

- Introduced the `normalizeCitations` function to improve citation formatting by removing periods after citations and handling footnote references.
- Updated `processInlineCitations` to ensure correct mapping of non-sequential citations and consolidate duplicate sources.
- Added comprehensive tests for the new citation functionalities, ensuring accurate citation processing and representation in AI-generated responses.
- Modified QASettings description for clarity regarding the inline citations feature.

* fix: address critical bug in citation processing for consecutive citations

- Enhanced the `normalizeCitations` function to correctly handle consecutive citations without spaces, ensuring accurate mapping and representation in output.
- Added a test case to replicate and validate the fix for the identified bug, confirming that both citations are processed and displayed correctly.
- Updated existing citation handling logic to improve robustness and prevent unconverted citations in the output.

* refactor: improve citation handling and enhance citation utilities

- Refactored `CopilotPlusChainRunner` to streamline citation source management, replacing `lastCitationLines` with `lastCitationSources` for better clarity and structure.
- Updated the `addFallbackSources` function to handle citation formatting more effectively, ensuring proper display of titles and paths.
- Enhanced the `normalizeCitations` function to support multiple citations in brackets, ensuring correct sorting and mapping of citation numbers.
- Added new test cases to validate the handling of alternate headings, HTML summaries, and multiple citations, improving overall robustness of citation processing.
- Introduced `getWebSearchCitationInstructions` for web-specific citation guidance, enhancing user experience with clear instructions for inline citations.

* fix: move documentations

* refactor: normalize footnote rendering in ChatSingleMessage component

- Introduced `normalizeFootnoteRendering` function to streamline footnote display by removing separators and backreferences, and fixing numbering artifacts.
- Updated `ChatSingleMessage` to utilize the new normalization function, enhancing the clarity and presentation of footnotes in chat messages.
- Added unit tests for `normalizeFootnoteRendering` to ensure proper functionality and handling of various footnote scenarios.

* test: enhance ChatSingleMessage tests and add deduplication tests for search tools

- Updated `ChatSingleMessage.test.tsx` to include `TooltipProvider` for improved rendering context.
- Refactored mock implementation for `obsidian` to streamline testing setup.
- Introduced new test file `SearchTools.dedupe.test.ts` to validate the deduplication logic in search tool sources, ensuring correct handling of duplicate entries while preserving the highest score.
- Added tests to verify deduplication behavior for various scenarios, enhancing overall test coverage and reliability.
2025-09-21 20:03:09 -07:00
Wenzheng Jiang
a7c7823ba4
Map Copilot aliases to builtin tools (#1829) 2025-09-20 19:48:43 -07:00
Zero Liu
e3f232f795
Fix chat crash issue (#1827) 2025-09-19 14:37:29 -07:00
Wenzheng Jiang
b65600522e
FIX: Supporting creating new folders in composer tools (#1796)
* Create non-existent folder in composer tools

* Improve getFile
2025-09-15 17:28:19 -07:00
Logan Yang
6f18acc0cc
Update README.md (#1819) 2025-09-12 22:17:15 -07:00
Logan Yang
b18782040e
feat: conditionally initialize VectorStoreManager and enhance relevant notes handling based on semantic search setting (#1817) 2025-09-12 18:26:18 -07:00
Logan Yang
0d477f869e
feat: enhance folder management by ensuring nested folder paths exist (#1816)
- Make sure nested folder works cross-platform
- Move defaults under copilot parent folder
- Update log file to only create on manual trigger
2025-09-12 16:30:30 -07:00
Logan Yang
5a2d4603b4
feat: add setting to generate AI chat title on save (#1811) 2025-09-11 18:36:40 -07:00
Logan Yang
b6da24d5ef
feat: move settings for context menu and markdown image handling to BasicSettings (#1810) 2025-09-11 16:50:22 -07:00
Logan Yang
5d93e6159d
Show embedding model (#1809) 2025-09-11 15:12:25 -07:00
Logan Yang
af5cc5ed19
feat: add search explanation table in log (#1805)
- Add search explanation table in log
- Clean up log for log file
- Implement open and clear log file commands
- Exclude log file from search (both lexical and semantic)
2025-09-11 01:05:34 -07:00
Emt-lin
3ddc42a9f1
fix: add safe area bottom padding to view content. (#1795) 2025-09-10 22:39:20 -07:00
Logan Yang
c033655b96
Implement log file (#1804) 2025-09-10 22:34:57 -07:00
Logan Yang
e50aa90187
Fix mobile embedded image passing (#1793)
- Replaced Buffer.from(...).toString("base64") with arrayBufferToBase64 in brevilabsClient, Chat, and ImageProcessor files for consistency and improved readability.
2025-09-10 18:08:51 -07:00
Emt-lin
0512e21471
fix: improve loading state management in project context updates. (#1787) 2025-09-05 12:38:00 -07:00
Emt-lin
946fb139ef
fix: Optimized the height of the modal box and the display of the close button on mobile devices. (#1786) 2025-09-05 12:10:14 -07:00
Zero Liu
bb8bbcb5d1
Only scroll to bottom when user messages are added (#1788) 2025-09-05 12:09:07 -07:00
Wenzheng Jiang
9239dab78a
Improve the regex for composer codeblock (#1778) 2025-09-03 13:51:34 -07:00
Logan Yang
d6ad7162c5
3.0.2 (#1777) 2025-08-30 22:59:37 -07:00
Wenzheng Jiang
ea66cfe56e
Switch to the new file when creating files with composer tools. (#1775)
* Switch to the new file when creating files with composer tools.

* Handle file creation race condition with improved error handling

Co-authored-by: jwzh.hi <jwzh.hi@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-08-30 22:55:36 -07:00
Logan Yang
8f6f98cdb1
Fix url processing with image false triggers (#1776)
- Implemented `extractNoteContent` method to gather content from `<note_context>` and `<active_note>` blocks, excluding `<url_content>`.
- Updated image processing logic to utilize extracted note content, ensuring relevant images are included when the `passMarkdownImages` setting is enabled.
2025-08-30 22:47:39 -07:00
Zero Liu
14f805c6b9
Fix chat input responsiveness (#1770) 2025-08-29 23:23:40 -07:00
Wenzheng Jiang
e75bb4cc46
Fix canvas parsing in writeToFile tool (#1773) 2025-08-29 20:53:59 -07:00
Logan Yang
f8123f891c
Update README.md (#1769) 2025-08-27 22:21:56 -07:00
Logan Yang
d98768273d
3.0.1 (#1768) 2025-08-27 22:12:46 -07:00
Logan Yang
bc16faba67
Enhance multimodal message handling in VaultQAChainRunner by processing text content for images. This ensures that the user message context is included in the LLM requests. (#1767) 2025-08-27 22:02:32 -07:00
Lawrence Sinclair
e4515e5241
Add MSeeP.ai badge to README.md (#1754) 2025-08-27 21:52:11 -07:00
Wenzheng Jiang
094d3e4839
Restrict replaceInFile for large files (#1760) 2025-08-27 21:44:32 -07:00
Shuhei Kadowaki
427b62a68d
Fix context not being sent to LLM for [[note]] references (#1766)
- Update `LLMChainRunner` to use processed message text (with context)
  for multimodal content
- This ensures that when using `[[note title]]` syntax, the note content
  is properly included in LLM requests

Closes #1763.
2025-08-27 21:44:07 -07:00
Yizong ZHOU
2785b170d5
remove duplicated feature bullet point (#1756) 2025-08-26 15:33:41 -07:00
Logan Yang
d85420bc06
3.0.0 (#1753)
* Revise README.md to highlight new features and improvements in Copilot V3

* 3.0.0
2025-08-25 21:23:58 -07:00
Logan Yang
eb830dfa6f
Support image and chat context menu in free modes (#1746) 2025-08-25 19:59:57 -07:00
Logan Yang
b446a68b02
Update default embedding requests per minute setting to 60 and adjust related description in QASettings for clarity (#1742) 2025-08-22 22:17:45 -07:00
Logan Yang
e12f4b7a40
Update default chat model and chores (#1741) 2025-08-22 21:38:54 -07:00
Logan Yang
41bb213be6
feat: add back garbage collection command for Copilot index (#1740) 2025-08-22 21:16:27 -07:00
Logan Yang
d1ddbd766c
Use salient terms for lexical ranking (#1739) 2025-08-22 20:40:01 -07:00
James Zhang
e045e50fe2
fix(embeddingManager.ts): fix embedding with LM Studio (#1735) 2025-08-22 18:29:35 -07:00
Logan Yang
a0d5d0b8ea
Go back to orama for semantic search (#1737)
* refactor: transition to semantic search with VectorStoreManager

- Updated the search architecture to utilize VectorStoreManager for semantic indexing and retrieval, replacing the legacy MemoryIndexManager.
- Removed deprecated Orama components and streamlined the search process for improved performance and clarity.
- Enhanced settings management by removing legacy search options and clarifying semantic search configurations.
- Refactored various components to support the new semantic search system, ensuring consistent behavior and improved user experience.
- Updated documentation to reflect changes in search functionality and settings management.

* feat: add optional parameters for search configuration in ChainManager and VaultQAChainRunner

- Introduced new optional parameters: timeRange, textWeight, returnAll, and useRerankerThreshold to enhance search configuration flexibility.
- Updated findRelevantNotes to correctly compute average embeddings by dividing the sum by the number of embeddings, ensuring accurate average calculations.
- Enhanced SearchTools to include timeRange and textWeight in search tool configurations, improving search customization options.

* feat: add textWeight and additional parameters to search configuration

- Introduced textWeight, timeRange, returnAll, and useRerankerThreshold as optional parameters in the CopilotPlugin to enhance search configuration flexibility.
- Updated the search logic to accommodate these new parameters, improving customization options for users.

* refactor: remove deprecated indexing components and streamline search architecture

- Deleted the IndexingNotificationManager, IndexingPipeline, IndexingProgressTracker, and IndexPersistenceManager classes to simplify the codebase and remove unused functionality.
- Updated README.md to reflect the removal of semantic search features and clarify the current search architecture.
- Enhanced documentation to provide clear guidance on the new chunk-based lexical search system, emphasizing its performance and memory efficiency.
- Removed associated tests to maintain code clarity and focus on active components.

* feat: introduce LLM timeout configuration and enhance query handling

- Added LLM_TIMEOUT_MS constant for a standardized timeout of 10 seconds for LLM operations.
- Updated HybridRetriever to utilize withTimeout for query rewriting, ensuring operations respect the new timeout.
- Adjusted QueryExpander and SearchCore to use LLM_TIMEOUT_MS for consistent timeout handling across components.
- Enhanced localSearchTool to delegate between lexical and semantic search based on settings, improving search flexibility.

* feat: enhance localSearch handling and tool confirmation messages

- Added special handling in getToolDisplayName for localSearch to display the search type based on settings.
- Updated getToolConfirmtionMessage to include salient terms for lexical search when applicable, improving user feedback during tool calls.
- Integrated settings retrieval to conditionally display relevant information based on user preferences.

* feat: add number of partitions setting to QASettings for improved indexing control

- Introduced a new setting for "Number of Partitions" in QASettings, allowing users to configure the number of partitions for the Copilot index.
- Updated the UI to provide a description and options for selecting the number of partitions, enhancing user experience and flexibility in managing large vaults.
- Removed the previous conditional rendering of the partitions setting based on semantic search, streamlining the settings interface.

* fix: update default requests per minute setting in QASettings

- Changed the default value for "Requests per Minute" from 90 to 30 to better align with user needs and prevent rate limiting issues.
- Adjusted the maximum value for the setting from 300 to 60, ensuring users stay within optimal limits for embedding requests.
- Updated the value assignment to ensure it does not exceed the new maximum, enhancing the robustness of the settings configuration.
2025-08-22 18:17:53 -07:00
Logan Yang
615330f4b8
fix: AI response streaming UI broken with react error 409 (#1723) 2025-08-19 21:38:08 -07:00
Emt-lin
9e77e4b86d
Optimize error msg and Improve UI (#1734)
* feat: Output more error messages for easier user viewing.

* feat: Optimize UI text style and color consistency.
2025-08-19 19:03:25 -07:00
Logan Yang
18f6405c76
Search v3 improvement 2 (#1733)
* feat: implement chunk-based search architecture and enhance retrieval consistency

- Introduced a new `ChunkManager` for managing note chunking, enabling efficient handling of large documents.
- Added comprehensive tests for `ChunkManager`, `SearchCore`, and `TieredLexicalRetriever` to ensure consistent chunk retrieval and scoring.
- Updated `SearchCore` to utilize chunk-based retrieval, improving performance and relevance in search results.
- Enhanced `FullTextEngine` to support chunk indexing, optimizing memory usage and search accuracy.
- Refactored scoring mechanisms to maintain chunk order and consistency across search results.
- Updated documentation to reflect changes in search architecture and chunking strategies.

feat: integrate ChunkManager into SearchCore and related components

- Added ChunkManager to SearchCore for improved chunk-based retrieval.
- Updated FullTextEngine to accept ChunkManager as an optional parameter, enhancing memory management.
- Refactored TieredLexicalRetriever to utilize the shared ChunkManager instance from SearchCore, ensuring consistent chunk handling.
- Enhanced tests for TieredLexicalRetriever to mock ChunkManager functionality, validating chunk retrieval behavior.

* feat: add enableLexicalBoosts flag to enhance search capabilities. Fix expanded term in ranking bug.

- Introduced `enableLexicalBoosts` setting in `CopilotSettings` to control the application of lexical boosts during search operations.
- Updated `SearchCore` to utilize the new setting, allowing for conditional application of folder and graph-based relevance boosts.
- Enhanced `QueryExpander` and related interfaces to include `expandedTerms`, capturing LLM-generated terms for improved recall.
- Added tests to validate the behavior of lexical boosts based on the new setting, ensuring expected functionality in various scenarios.
- Updated documentation to reflect the new feature and its implications for search performance and relevance.

* feat: enhance SearchCore to conditionally skip lexical and semantic searches based on semantic weight

- Updated SearchCore to skip lexical search when semantic weight is 100% and skip semantic search when weight is 0%, improving performance.
- Added logging to indicate when searches are skipped, enhancing traceability.
- Introduced new tests to validate the behavior of search execution under different semantic weight scenarios, ensuring expected functionality.

* feat: enhance ChunkManager and SearchCore with input validation and error handling

- Improved input validation in `ChunkManager` to ensure valid note paths and handle edge cases, including empty and excessively long inputs.
- Enhanced error handling in `SearchCore` to gracefully manage failures during chunk retrieval and fallback to simple grep results when necessary.
- Updated `QueryExpander` to utilize a race condition approach for timeout handling, improving reliability during query expansion.
- Removed deprecated tests and added new tests to validate the behavior of input validation and error handling, ensuring robustness in various scenarios.
- Updated documentation to reflect changes in input handling and error management strategies.

* fix: update semanticWeight assignment to use nullish coalescing operator

- Changed the assignment of `semanticWeight` in `TieredLexicalRetriever` to utilize the nullish coalescing operator (`??`) for better handling of undefined values, ensuring a default value of 0.6 is applied when no weight is provided.

* feat: enhance CopilotPlusChainRunner and MemoryIndexManager for improved source handling

- Added deduplication of sources in CopilotPlusChainRunner to ensure unique results.
- Refactored MemoryIndexManager to aggregate scores by chunk ID instead of note path, improving consistency in scoring.
- Introduced a new method for aggregating scores per chunk, enhancing the retrieval process.
- Updated tests to validate the new behavior in source handling and scoring aggregation.

* feat: standardize chunk ID formatting and enhance ChunkManager functionality

- Updated chunk ID format to consistently use zero-padding for indices across all components, ensuring uniformity in chunk identification.
- Refactored ChunkManager to include automatic cache validation and regeneration for chunk retrieval, improving reliability and performance.
- Introduced content hash for chunks to validate content integrity and prevent stale data usage.
- Enhanced tests to validate new chunk ID formatting and ensure consistent behavior across various scenarios.
- Updated documentation to reflect changes in chunk ID handling and retrieval processes.

* feat: streamline index clearing process in registerCommands

- Removed redundant file removal logic for clearing local Copilot semantic index.
- Introduced IndexPersistenceManager to handle index file clearing, improving code clarity and maintainability.
- Updated user notification to reflect the new index clearing process.

* feat: add comprehensive tests for FullTextEngine's clear method

- Introduced multiple test cases for the clear method in FullTextEngine to ensure safe operation under various conditions, including uninitialized index, multiple clear calls, and handling of index cleanup methods.
- Enhanced the clear method to handle scenarios where the index may throw errors during cleanup, ensuring robust state management.
- Verified memory management reset during the clear operation, improving overall reliability of the FullTextEngine.

* feat: implement TimeoutError and withTimeout utility for enhanced error handling

- Introduced a new TimeoutError class for consistent timeout error management across operations.
- Added withTimeout utility function to execute operations with a specified timeout, utilizing AbortController for proper cancellation.
- Updated QueryExpander and SearchCore to leverage withTimeout for handling LLM requests, improving reliability during long-running operations.
- Enhanced tests for withTimeout and TimeoutError to ensure correct behavior under various scenarios, including timeout handling and error propagation.
- Updated documentation to reflect the new timeout handling features and their usage.

* fix: OOM error for single file index update

- Introduced memory safety checks in IndexPersistenceManager to prevent out-of-memory (OOM) errors during record processing.
- Implemented batch processing for writing records and updating file records, improving memory efficiency.
- Added methods for estimating memory usage and checking memory safety thresholds.
- Enhanced MemoryIndexManager to utilize incremental updates for modified files, reducing memory load during reindexing.
- Updated tests to validate the new memory-safe operations and ensure correct behavior under high memory usage scenarios.

* fix: have semantic search completely independent of grep and lexical search

* refactor: update chunk ID formatting to non-padded indices

- Changed chunk ID format in ChunkManager and related components to use non-padded indices, improving consistency and simplifying ID generation.
- Updated tests to reflect the new ID format and ensure correct behavior across various scenarios.
- Removed legacy checks for old padded formats in tests, streamlining the codebase.

* feat: add SemanticSearchToggleModal for enabling/disabling semantic search

- Introduced SemanticSearchToggleModal to confirm user actions for enabling or disabling semantic search.
- Updated QASettings to utilize the new modal for handling semantic search state changes, providing user feedback and guidance on index management.

* refactor: streamline embedding model handling in BasicSettings and QASettings

- Removed the embedding model selection from BasicSettings, consolidating its functionality within QASettings.
- Introduced a new handler for setting the default embedding model in QASettings, ensuring user confirmation via RebuildIndexConfirmModal.
- Updated the UI components in QASettings to provide detailed descriptions and tooltips for embedding model selection, enhancing user experience and clarity.

* refactor: remove unused components and streamline memory management

- Deleted the SemanticReranker and VaultPathValidator classes to simplify the codebase and remove unused functionality.
- Removed the index.ts file for v3 search, consolidating exports and reducing clutter.
- Updated MemoryIndexManager to utilize a simplified in-memory approach for reindexing, enhancing performance and memory efficiency.
- Adjusted tests to reflect changes in the MemoryIndexManager's reindexing strategy, ensuring consistent behavior and improved reliability.
2025-08-19 19:00:15 -07:00
Logan Yang
e26561ba40
feat: introduce unified search system with legacy support (#1728)
* feat: introduce unified search system with legacy support

- Added a new `SearchSystem` interface and `SearchSystemFactory` to manage both legacy and v3 search implementations.
- Updated various components to utilize the new factory for creating retrievers and managing indexing operations.
- Introduced `useLegacySearch` setting to toggle between legacy and v3 search systems, enhancing user control over search functionality.
- Refactored existing search-related code to streamline indexing and retrieval processes, improving overall performance and maintainability.
- Updated documentation to reflect changes in search architecture and settings management.

* feat: enhance V3 indexing responses with document count

- Updated the V3SearchIndexer to return the document count after incremental and full indexing operations.
- Modified success messages to include the number of documents indexed, improving user feedback and clarity on indexing results.

* feat: improve error handling and user feedback for indexing operations

- Enhanced the indexing process by adding error handling for both incremental and full indexing operations, providing users with clear notifications in case of failures.
- Updated the `refreshVaultIndex` and `forceReindexVault` functions to log errors and display messages when indexing fails, improving overall user experience and transparency.
- Refactored the main indexing logic in `CopilotPlugin` to ensure proper loading of indexes based on the selected strategy, enhancing functionality and reliability.

* fix: update indexing logic for search system consistency

- Changed comments to clarify that the index loading occurs even when search is disabled.
- Refactored the index loading process to consistently use `SearchSystemFactory`, enhancing code clarity and maintainability.
- Updated the condition for enabling semantic search to account for the new `useLegacySearch` setting, improving search functionality control.
2025-08-17 16:32:21 -07:00
Emt-lin
214fd8d44d
feat: enhance optional parameter controls with toggle functionality. (#1716)
* feat: enhance optional parameter controls with toggle functionality.

* fix: fix the issue of the ParameterControl Fails with Undefined and Zero.

* feat: Change Reasoning Effort/Verbosity parameters to optional parameters.
2025-08-15 21:32:29 -07:00
Zero Liu
315ba1aca7
Update live mode check (#1719) 2025-08-15 21:25:13 -07:00
Logan Yang
7bc84923fa
Improve Search v3 (#1721)
* Implement graph boost and remove graph expander

- Removed the GraphExpander class and its associated tests, streamlining the search process.
- Eliminated the graphHops setting from CopilotSettings and related sanitization logic.
- Updated SearchCore and TieredLexicalRetriever to utilize direct candidate limiting without graph expansion.
- Introduced GraphBoostCalculator for link-based reranking, enhancing search result relevance based on candidate connections.
- Updated documentation to reflect changes in search architecture and removed references to graph expansion.

* Refactor search scoring and implement folder boosting

- Updated the search scoring mechanism to remove the grep prior, simplifying the ranking process.
- Introduced the FolderBoostCalculator to apply logarithmic boosts for documents in folders with multiple matches, enhancing result relevance.
- Updated SearchCore to integrate folder boosting after the weighted RRF fusion.
- Refactored FullTextEngine to remove the folder boosting logic, centralizing it in the new calculator.
- Added comprehensive tests for the FolderBoostCalculator to ensure correct boost application and behavior.
- Revised documentation to reflect changes in the search architecture and boosting strategies.

* Enhance search functionality with scoring normalization and explanations

- Introduced ScoreNormalizer to apply z-score and tanh normalization, preventing auto-1.0 scores and improving score distribution.
- Updated FullTextEngine to include lexical match explanations in search results, providing insights into scoring factors.
- Enhanced SearchCore to pass the same candidate set to both full-text and semantic searches, ensuring consistency in results.
- Implemented detailed logging for candidate filtering and search processes, improving traceability and debugging.
- Added comprehensive tests for ScoreNormalizer and updated existing tests to validate new scoring behaviors.

* Add semantic weight slider

- Added `semanticSearchWeight` to `DEFAULT_SETTINGS` for controlling the balance between semantic and lexical search.
- Updated `SettingItem` and `SettingSlider` components to support a suffix for better user experience in displaying semantic weight.
- Introduced a new slider in the QASettings UI for adjusting semantic search weight, allowing users to set preferences between 0% and 100%.
- Implemented tests for the new semantic weight functionality in `SearchCore`, ensuring correct application of weights during search operations.

* Enhance memory management and search functionality

- Added `lexicalSearchRamLimit` to `DEFAULT_SETTINGS` for controlling RAM usage in lexical search, with a default of 100 MB and a range of 20-1000 MB.
- Updated `MemoryManager` to utilize the new RAM limit setting, ensuring proper memory allocation and management during search operations.
- Refactored `GrepScanner` to simplify batch size handling and yield intervals, improving performance.
- Introduced unit tests for `MemoryManager` to validate RAM limit functionality and memory tracking behaviors.
- Removed the deprecated `platformUtils` module, streamlining the codebase.

* Enhance sources modal with source explanations

- Updated source structures across various components to include an optional `explanation` field, providing additional context for search results.
- Modified `SourcesModal` to display explanations in a collapsible format, improving user experience and clarity.
- Adjusted `SearchCore`, `TieredLexicalRetriever`, and related classes to preserve and pass explanation data through the search pipeline.
- Refactored relevant utility functions to accommodate the new explanation field, ensuring consistency in source handling.
- Enhanced logging and documentation to reflect changes in search result structures and explanation handling.

* Enhance weighted RRF function to merge explanations

- Updated the `weightedRRF` function to merge explanations from both lexical and semantic search results, improving the clarity and depth of search result context.
- Implemented logic to combine lexical matches and semantic scores, ensuring comprehensive explanation handling for each item.
- Adjusted the explanation merging process to maintain existing scores while integrating new data, enhancing the overall search experience.

* Update QASettings component to clarify description for Max Sources setting

- Changed the description for the Max Sources slider to specify that Copilot now retrieves relevant notes instead of blocks, and updated the default value for N from 3 to 15, enhancing user understanding of the setting's functionality.

* Refactor search architecture to enable parallel execution of lexical and semantic searches

- Updated the search pipeline to run full-text and semantic searches concurrently, improving performance and reducing latency.
- Introduced new methods for executing lexical and semantic searches separately, enhancing code organization and clarity.
- Adjusted documentation to reflect changes in search execution and added details on the parallel search process.
- Increased LLM generation timeout and refined candidate handling for improved search accuracy.

* Increase default tool timeout to 60 seconds and update search result formatting to include full content for LLM context.

* Enhance graph boost functionality and improve search performance

- Updated SourcesModal to include detailed explanations for graph connections, enhancing user understanding of search results.
- Refactored SearchCore to apply graph boosts more effectively, including logging improvements for better traceability.
- Modified GraphBoostCalculator to support new connection types and weights, allowing for more nuanced scoring based on backlinks, co-citations, and shared tags.
- Adjusted FolderBoostCalculator to reduce the maximum boost factor, ensuring more balanced scoring.
- Enhanced FullTextEngine to defer index creation for improved UI responsiveness and added memory management features.
- Updated tests for GraphBoostCalculator to validate new functionality and ensure accurate scoring behavior.

* Improve search signals

- Updated QueryExpander to include original user query and expanded variants in the response structure, improving clarity and usability.
- Refactored SearchCore to log search details with reduced verbosity, focusing on significant query expansion results and candidate counts.
- Improved FullTextEngine to ensure scoring is based solely on the original query and salient terms, while expanded queries are used only for recall.
- Enhanced logging throughout the search process for better traceability and debugging, including detailed results summaries and performance metrics.

* Fix tests

* Update field weights in FullTextEngine for improved scoring consistency

- Adjusted weights for metadata fields (path, headings, tags, props, links) from 2.5 and 2 to 1.5, ensuring a more balanced scoring system.
- Updated corresponding tests to reflect the new weight values, maintaining alignment between implementation and test cases.

* Refactor modelAdapter to improve query handling and guidelines

- Moved the import statement for BaseChatModel to the correct position for better organization.
- Added new guidelines for query handling, emphasizing the use of personal knowledge or notes before web searches, and clarifying the use of tool names in responses.

* Update README.md to reflect changes in search scoring and normalization

- Adjusted field weights for Full-Text and Graph Boosts to improve scoring consistency and balance.
- Changed normalization method from Z-score to Min-max, enhancing score range management.
- Updated documentation to clarify the new scoring parameters and their implications for search results.

* Refactor localSearch result processing for improved consistency and clarity

- Introduced a new utility module for handling localSearch results, including formatting and source extraction.
- Updated AutonomousAgentChainRunner and CopilotPlusChainRunner to utilize the new processing methods, ensuring consistent handling of localSearch outputs.
- Enhanced error handling and logging for localSearch results, improving traceability and user feedback.
- Removed redundant code related to localSearch result processing, streamlining the execution flow.

* Enhance error handling in IndexingPipeline for embedding failures

- Added detailed logging for token counts when embedding errors occur, aiding in debugging and user guidance.
- Implemented a try-catch block around the embedding process to capture and log specific error messages related to token limits.
- Introduced ChatModelManager to analyze token counts for each chunk, providing insights into potential batch size adjustments.

* Reduce maxCandidates in GraphBoostCalculator from 50 to 3 for improved search performance and relevance.

* Enhance GraphBoostCalculator with semantic similarity threshold and max candidates adjustments

- Increased maxCandidates from 3 to 10 to allow for more comprehensive result analysis.
- Introduced semanticSimilarityThreshold to boost results based on a minimum similarity score of 0.75, improving relevance in search results.
- Updated filtering logic to respect both the semantic threshold and max candidates limit, ensuring only the most relevant results are boosted.

* Fix tests, update readme

- Added a Jest mock for ChatModelManager to simulate token counting without requiring the actual SDK, streamlining test execution and improving isolation of unit tests.

* Fix ToolResultFormatter and add tests

- Introduced comprehensive tests for the ToolResultFormatter class, covering various scenarios for both local and web search formats.
- Validated handling of XML-wrapped search results, including cases with multiple documents, empty results, and malformed XML.
- Ensured fallback to JSON parsing for non-XML results and tested for proper formatting of output messages.
- Enhanced test coverage to improve reliability and maintainability of the formatting logic.

* Enhance FolderBoostCalculator with relevance ratio and improved scoring logic

- Added totalDocsInFolder and relevanceRatio to the FolderBoostResult interface to provide better insights into folder statistics.
- Updated the boost calculation logic to apply boosts based on a minimum relevance ratio, ensuring only folders with a significant portion of relevant documents receive boosts.
- Revised README.md to reflect changes in folder boost calculations, including new requirements for minimum relevant documents and relevance ratio thresholds.
- Enhanced unit tests for FolderBoostCalculator to cover new scenarios and ensure correct behavior under various conditions.

* Fix test warnings

- Added a check to ensure the getConfig method exists before calling it, enhancing compatibility with test environments.
- Updated error logging to only occur in non-test environments, reducing unnecessary console output during testing.
2025-08-15 21:24:42 -07:00
Michael Rumyantsev
9823fb19d5
fix(utils): Fix handling of headers in the safeFetch util (#1720) 2025-08-15 20:31:22 -07:00
Logan Yang
021153606f
Update docs to make it clear that url processing is for user input only (#1714)
- Updated ContextProcessor to clarify that note content is included as-is without URL extraction.
- Modified ContextManager and Mention classes to ensure only URLs from user input are processed, improving clarity on URL handling.
- Added documentation to emphasize the importance of processing URLs exclusively from user chat input, aligning with existing functionality.
2025-08-11 22:47:21 -07:00
Logan Yang
8f8f4f7454
Adapt gpt5 (#1713)
* Use responses api for GPT-5 models

- Updated configuration for GPT-5 models to utilize the Responses API for verbosity settings, ensuring proper handling of verbosity as a nested parameter.
- Added logging to indicate when the Responses API is enabled for GPT-5 models during model instance creation.
- Refactored model instance creation logic to automatically set the useResponsesApi flag for GPT-5 models, improving clarity and functionality in model configuration.

* Add GPT-5 specific enhancements to model adapter

- Introduced a method to check if the model is part of the GPT-5 family, enhancing model-specific behavior.
- Added GPT-5 specific instructions for tool usage, improving clarity on tool call requirements.
- Implemented a response sanitization method for GPT-5 to handle multiple iterations, ensuring only the most relevant response is returned.
- Refactored user message enhancement logic to streamline tool reminder messages based on the presence of required tools.

* Enhance tool execution responses with structured JSON format

- Updated the `executeSequentialToolCall` function to return an empty JSON object instead of a plain string when tools return null/undefined results, improving compatibility.
- Refactored the `writeToFileTool` and `replaceInFileTool` to return structured JSON responses, ensuring consistent parsing and better handling of results.
- Modified the `webSearchTool` to return a structured JSON array of results, aligning with the new response format for improved consistency across tools.
- Enhanced the `ToolResultFormatter` to handle the new JSON response formats, ensuring user-friendly display of tool results in the UI.
2025-08-11 22:21:55 -07:00
Wenzheng Jiang
7a884ab473
Pass necessary context to validateLicenseKey (#1708)
* Pass necessary context to validateLicenseKey

* Resolve comments

* Prevent overriding reserved keys in Brevilabs client context

Co-authored-by: logancyang <logancyang@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: logancyang <logancyang@gmail.com>
2025-08-11 22:01:22 -07:00
Emt-lin
ea9e96b4d6
feat: remove the verifyApiKey button in the ApiKeyDialog. (#1711) 2025-08-11 20:48:50 -07:00
Logan Yang
df3efaecc0
Implement vault search v3 (#1703)
* Add LexicalEngine and related interfaces for enhanced search functionality

- Implement LexicalEngine class utilizing FlexSearch for efficient document indexing and searching.
- Create Hit, SearchResult, SearchOptions, and RetrieverEngine interfaces to standardize search operations.
- Update package.json and package-lock.json to include new dependencies: flexsearch and p-queue.

* Add QueryExpander class and tests for enhanced search query expansion

* Add README for Tiered Note-Level Lexical Retrieval with multilingual support and enhanced search pipeline

* Implement v3 tiered search with GrepScanner, FullTextEngine, and GraphExpander

- Add GrepScanner for fast substring search (L0)
- Implement FullTextEngine with ephemeral FlexSearch index (L1)
- Add GraphExpander for link-based candidate expansion
- Create MemoryManager with platform-aware limits
- Implement weighted RRF fusion for result combination
- Add SemanticReranker placeholder for future integration
- Include comprehensive tests for core components
- Simplify code based on review: use TextEncoder, extract methods, streamline RRF
- Add clear logging for debugging search pipeline

The architecture follows a tiered approach:
1. Grep scan for initial candidates
2. Graph expansion to increase recall
3. Full-text search on expanded set
4. Optional semantic reranking
5. RRF fusion to combine signals

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* Enhance tiered retrieval recall with both rewritten queries and the expanded salient terms

- Added detailed example of end-to-end query processing in README.md
- Updated TieredRetriever to log salient terms during query expansion
- Modified FullTextEngine to index link basenames for improved searchability
- Adjusted NoteDoc interface to clarify link handling and indexing

* Refactor QueryExpander configuration and caching logic for improved clarity and performance; enhance GraphExpander documentation and testing; remove unused fields from NoteDoc interface.

* Refactor search retrieval system to wire in TieredLexicalRetriever

- Replaced HybridRetriever with TieredLexicalRetriever in ChainManager, VaultQAChainRunner, and main.ts for improved multi-stage retrieval.
- Updated README.md to reflect new integration tasks and performance benchmarks.
- Introduced TieredLexicalRetriever class to handle on-demand indexing and retrieval.
- Enhanced GrepScanner to support inclusion/exclusion patterns for file indexing.
- Modified TieredRetriever to combine expanded salient terms and improved logging for final results.
- Removed legacy index management in favor of ephemeral indexing with TieredLexicalRetriever.
- Updated interfaces and utility functions to support new retrieval logic.

* feat: Implement first working TieredLexicalRetriever and refactor search scoring

- Added comprehensive tests for TieredLexicalRetriever, covering folder boosting and result combination.
- Refactored TieredLexicalRetriever to utilize SearchCore instead of TieredRetriever.
- Enhanced FullTextEngine with improved scoring mechanisms, including field weighting and multi-field match bonuses.
- Introduced FuzzyMatcher utility for fuzzy matching capabilities, including Levenshtein distance and variant generation.
- Updated GrepScanner to prioritize path matching for faster search results.
- Implemented normalized scoring in RRF for better ranking consistency.

* feat: Update vault search result display to show snippet of content instead of full text

* feat: Enhance FullTextEngine to index frontmatter property values and improve search scoring

* feat: Improve scoring and logging

* feat: Refactor TieredLexicalRetriever to support time-based queries and improve document retrieval logic

* Remove TODO.md from tracking and add to .gitignore

- TODO.md is now a local-only development session tracker
- Prevents accidental commits of work-in-progress task lists
- Each developer can maintain their own TODO.md without conflicts

* feat: Update dependencies and enhance search functionality

- Upgraded axios to version 1.11.0 and electron to version 27.3.11 for improved performance and security.
- Refactored QueryExpander to limit queries to the original for strict fallback tests.
- Enhanced SearchCore to rank grep hits by evidence quality before fusion, improving retrieval accuracy.
- Implemented a new method in SearchCore to rank grep hits based on evidence strength.
- Updated TieredLexicalRetriever tests to ensure proper integration with mocked SearchCore.
- Improved FuzzyMatcher to include plural/singular normalization for better fuzzy matching.

* feat: Add tool call marker encoding/decoding

- Introduced new utility functions for encoding and decoding tool call markers to ensure safe embedding in HTML comments.
- Updated `updateChatMemory` to handle encoded tool call markers, preserving their integrity during memory storage.
- Enhanced logging to decode tool marker results for better readability while maintaining encoded formats for storage.
- Added comprehensive tests for tool call marker functionality to ensure correct encoding and decoding behavior.
- Refactored related components to integrate the new encoding/decoding logic seamlessly.

* feat: Enhance FullTextEngine search to support low-weight terms and improve scoring

- Updated FullTextEngine to accept low-weight terms in the search method, allowing for better handling of salient terms.
- Implemented downweighting for boolean and numeric tokens in the properties field to reduce noise in search results.
- Added a new test case to validate the downweighting behavior for boolean and numeric queries in FullTextEngine.
- Improved logging for full-text search results to provide clearer insights into the search process.

* feat: Update GraphExpander to enhance search recall with adaptive hop logic

- Implemented guardrails in GraphExpander to adjust hop depth based on the number of grep hits: allows +1 hop for small sets (<5) and limits to 1 hop for large sets (≥50).
- Updated README.md to reflect new guardrail logic and scoring normalization in search results.
- Enhanced test cases to validate the new behavior of skipping co-citations for large result sets and allowing additional hops for smaller sets.

* feat: Filter out background tools during streaming to enhance user experience

- Added logic to determine and filter out background tools, preventing their names from appearing in the tool call display during streaming.
- Implemented a mechanism to include partial tool names only if they meet a specified length threshold, improving clarity in tool call presentations.

* feat: Enhance search tools to support time-based queries and display modified time

- Updated localSearchTool to ensure a healthy cap on max source chunks for time-based queries, improving recall.
- Modified ToolResultFormatter to display actual modified time for time-filtered results, enhancing clarity in search results.
- Adjusted output formatting to differentiate between recency and relevance scores based on query type.

* feat: Introduce semantic search capabilities with Memory Index support

- Added `enableSemanticSearchV3` setting to control the new semantic search feature.
- Implemented `MemoryIndexManager` for managing in-memory vector indexing with JSONL persistence.
- Enhanced `SearchCore` to utilize semantic retrieval based on the new memory index.
- Introduced command to build the semantic memory index, improving search accuracy and retrieval efficiency.
- Updated relevant components to integrate semantic search functionality seamlessly.

* feat: Add unit tests for MemoryIndexManager to validate functionality

- Introduced comprehensive tests for the MemoryIndexManager, covering scenarios such as loading from a file, building a vector store, and indexing vault contents.
- Implemented a mock embeddings API to facilitate testing without external dependencies.
- Added a utility function to reset the MemoryIndexManager state between tests, ensuring isolation and reliability of test outcomes.

* feat: Enhance MemoryIndexManager and related components for improved semantic indexing

- Introduced incremental indexing capabilities in MemoryIndexManager to update the JSONL index with new or modified files, enhancing efficiency.
- Updated commands to utilize the new incremental indexing method, providing users with real-time updates to the semantic memory index.
- Refactored existing indexing logic to support partitioned writes, improving performance and manageability of large datasets.
- Enhanced user notifications during indexing processes to provide better feedback on progress and status.
- Added a setting to enable semantic search, allowing users to blend semantic similarity into search results seamlessly.

* feat: Refine scoring display and enhance search result handling

- Updated SourcesModal to display relevance scores with four decimal places for consistency with SearchCore logs.
- Enhanced TieredLexicalRetriever to include a new `rerank_score` field for better score management and consistency across search results.
- Modified the combineResults method to ensure that title matches retain their original score while incorporating a fused score as `rerank_score`.
- Adjusted formatting in SearchTools to ensure both `score` and `rerank_score` reflect the same final score when present.
- Updated ToolResultFormatter to display scores with four decimal places, improving clarity in search result presentations.

* refactor: Simplify relevant notes retrieval by removing VectorStoreManager dependency

- Removed the use of VectorStoreManager in the relevant notes fetching logic, streamlining the process.
- Integrated MemoryIndexManager for improved handling of in-memory indexing and note retrieval.
- Enhanced error handling to ensure relevant notes are only fetched when the embedding index is available.
- Updated related functions to utilize the new memory index approach, improving performance and reliability.

* refactor: Remove VectorStoreManager dependency and streamline indexing logic

- Eliminated the VectorStoreManager from various components, transitioning to MemoryIndexManager for indexing and retrieval.
- Updated project and chain managers to remove unnecessary dependencies, enhancing code clarity and maintainability.
- Improved error handling and indexing conditions, particularly for mobile users, ensuring a more robust initialization process.
- Refactored settings components to utilize the new memory indexing approach, simplifying the user experience and reducing legacy code.

* chore: Mark legacy components as deprecated in preparation for v3 transition

- Annotated various files including DebugSearchModal, OramaSearchModal, chunkedStorage, dbOperations, hybridRetriever, and vectorStoreManager as deprecated, indicating they are obsolete in v3.
- Updated README.md to reflect the current implementation status and migration notes, emphasizing the removal of Orama-based modules and the transition to MemoryIndexManager for indexing and retrieval.

* feat: Implement file tracking and reindexing for modified files

- Introduced a new FileTrackingState interface to manage the last active file and its modification time.
- Enhanced the CopilotPlugin to opportunistically reindex the previous active file if it was modified while active, contingent on semantic search settings.
- Updated MemoryIndexManager to support reindexing of single modified files, improving efficiency in handling changes.
- Added unit tests for reindexSingleFileIfModified to ensure correct functionality and performance.

* feat: Add graph hops setting for enhanced search result expansion

- Introduced a new `graphHops` setting in `CopilotSettings` to control the number of hops for graph expansion during search, with a default value of 1 and a range of 1-3.
- Updated the `sanitizeSettings` function to validate the `graphHops` value.
- Enhanced the `SearchCore` and `TieredLexicalRetriever` classes to utilize the `graphHops` setting for improved search result relevance.
- Updated documentation in `README.md` to reflect the new feature and its security optimizations.

* refactor: Improve error handling and indexing logic in MemoryIndexManager and related components

- Replaced console error logging with structured logging using logError and logWarn for better error tracking.
- Enhanced the refresh and reindexing functions to ensure proper user notifications and error handling.
- Updated the logic for managing indexed files, including handling exclusions and ensuring accurate reporting of indexed and unindexed files.
- Implemented rate limiting in the MemoryIndexManager to optimize embedding requests and prevent overloading the service.
- Refactored chunk processing to improve efficiency and clarity in the indexing workflow.

* chore: Update README.md to reflect final session completion and key fixes

- Expanded the final session summary with a date and detailed list of completed features and fixes, including UI enhancements and improved logging practices.
- Clarified the status of deferred features and provided migration notes for better user guidance.
- Ensured documentation aligns with the latest implementation changes and optimizations.

* chore: Update memory limits in README.md and MemoryManager.ts for improved performance

- Adjusted memory limits for mobile and desktop platforms in both README.md and MemoryManager.ts to reflect increased capacity (20MB mobile, 100MB desktop).
- Ensured documentation aligns with the latest implementation changes for better clarity on resource management.

* feat: Integrate HyDE document generation into SearchCore for enhanced semantic search

- Implemented a new method to generate hypothetical documents (HyDE) to improve semantic search capabilities.
- Added timeout handling for HyDE generation to ensure graceful error management.
- Updated SearchCore to utilize HyDE documents in search queries, enhancing the relevance of results when semantic search is enabled.
- Introduced unit tests to validate the integration and functionality of HyDE generation within the search process.

* refactor: Enhance MemoryIndexManager with public methods for better indexing management

- Introduced public methods in MemoryIndexManager to streamline access to indexed file paths, check if a file is indexed, retrieve embeddings, and clear the index.
- Updated related components to utilize these new methods, improving code clarity and reducing direct access to internal properties.
- Enhanced error handling and user notifications during memory index operations.

* feat: Implement indexing notification and progress management

- Introduced the IndexingNotificationManager to handle UI notifications during indexing operations, allowing users to pause or stop the process.
- Added the IndexingProgressTracker to track progress across multiple files, enhancing user feedback during indexing.
- Developed the IndexingPipeline to manage the chunking and processing of files into embeddings, improving the overall indexing workflow.
- Created the IndexPersistenceManager for managing the persistence of indexed data, ensuring efficient storage and retrieval of index records.
- Refactored MemoryIndexManager to utilize the new components, streamlining the indexing process and improving code organization.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 16:58:04 -07:00
Wenzheng Jiang
40fec215b9
Add tool call marker for the tool call that is being generated (#1694) 2025-08-08 13:01:26 -07:00
Emt-lin
fe26c8943b
feat: Add project new context loading ui. (#1695) 2025-08-08 09:59:00 -07:00
Logan Yang
eca41a929b
Update version to 2.9.5 (#1700)
- Update manifest.json to 2.9.5
- Update versions.json to include 2.9.5
- Update package.json and package-lock.json to 2.9.5
2025-08-08 09:40:47 -07:00
Logan Yang
bc82d39756
Update manifest version to 2.9.5 for temperature fix release (#1699) 2025-08-08 09:35:53 -07:00
Logan Yang
8c532ecbdc
Add GPT5 models (#1698)
* Add GPT5 models

* Refactor BUILTIN_CHAT_MODELS: remove some models

* Fix temperature again
2025-08-08 09:30:46 -07:00
Anatolii Tsyplenkov
2292a0a6fd
Update Gemini Flash-Lite (#1675) 2025-08-06 13:42:55 -07:00
Wenzheng Jiang
4884d9513d
Setup Prompt test for Agent (#1688)
* Implement autonomous agent mode and new message architecture

- Implement autonomous agent mode with sequential thinking capabilities
- Migrate from sharedState to new clean message management architecture
- Add ChatManager, MessageRepository, and ContextManager for better separation of concerns
- Implement project-based chat isolation with separate message repositories per project
- Add ChatPersistenceManager for saving/loading chat history
- Refactor chain runner architecture with specialized runners (Autonomous, Project, VaultQA)
- Add XML tool call handling for better compatibility across LLM providers
- Introduce vault and web search toggles in settings
- Enhance model adapters with explicit tool usage instructions
- Add comprehensive test coverage for new architecture components
- Update UI components to use new ChatUIState instead of SharedState

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* Enhance context processing and documentation

- Added detailed XML structure for context notes, including metadata (creation and modification times) for notes and selected text.
- Introduced new tests for message lifecycle and XML tag formatting to ensure proper context handling.
- Created TODO.md to track technical debt and future improvements related to context processing and file handling.
- Updated CLAUDE.md to reference the new TODO.md for technical debt awareness.

This commit improves the clarity and functionality of context processing in messages, ensuring rich context is maintained throughout the message lifecycle.

* Fix composer instruction in new message implementation

* Add writeToFile tool to agent (#1632)

* Add writeToFile tool call to agent

* Update src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/tools/ComposerTools.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove unuseful comment

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix tests

* Enhance context processing with new XML tags for notes and selected text (#1633)

* Enhance context processing with new XML tags for notes and selected text

- Introduced new constants for XML tags: SELECTED_TEXT_TAG, VARIABLE_TAG, VARIABLE_NOTE_TAG, EMBEDDED_PDF_TAG, VAULT_NOTE_TAG, and RETRIEVED_DOCUMENT_TAG.
- Updated context processing to format embedded PDFs and notes with XML structure.
- Modified prompt processing to include XML tags for selected text and variables, improving clarity and structure in generated outputs.
- Enhanced tests to validate the new XML formatting in processed prompts.

* Implement XML escaping utility functions and integrate into context processing

- Added `escapeXml` and `escapeXmlAttribute` functions to handle XML special character escaping, enhancing security against XML injection.
- Updated context processing to utilize these functions for escaping note titles, paths, and content, ensuring proper XML formatting.
- Enhanced tests to validate XML escaping functionality across various scenarios, including selected text and variable names.
- Introduced new test files for XML utility functions to ensure comprehensive coverage and reliability.

* Refactor XML utility functions and update imports

- Moved `escapeXml` and `escapeXmlAttribute` functions from `utils/xmlUtils` to `LLMProviders/chainRunner/utils/xmlParsing`, enhancing modularity.
- Updated all relevant imports across the codebase to reflect the new location of XML utility functions.
- Removed obsolete `xmlUtils` file and its associated tests, consolidating XML handling in a single module for better maintainability.

* Add message creation callback in ChatManager for immediate UI updates (#1634)

- Introduced a new method `setOnMessageCreatedCallback` in ChatManager to allow setting a callback that triggers when a message is created.
- Updated ChatUIState to utilize this callback for notifying listeners, ensuring the UI updates immediately upon message creation.
- This enhancement improves the responsiveness of the chat interface by synchronizing message creation events with UI state updates.

* Enhance TimeTools tests for invalid expressions

- Added setup to mock console.warn in tests for invalid time expressions.
- Verified that the appropriate warning message is logged when parsing fails.
- Improved test coverage for handling various invalid time inputs.

* Refactor local search handling and restore sources display

- Unified tool handling in CopilotPlusChainRunner to fix websearch results being ignored when localSearch has results
- All tool outputs now go through the same prepareEnhancedUserMessage method
- Preserved QA format and instruction when only localSearch is used
- Added proper validation for localSearch results (non-empty documents array)
- Maintained time expression support for temporal queries
- Fixed source tracking and display in ChatUIState
- Enhanced error handling for JSON parsing of search results

* Enhance AutonomousAgentChainRunner to track tool calls and results (#1639)

- Introduced a new property to store LLM-formatted messages for memory management.
- Reset LLM messages at the start of each run to ensure fresh context.
- Updated response handling to include LLM-formatted outputs, enhancing memory context for future iterations.
- Modified the base class to accept an optional LLM-formatted output parameter for improved context saving.
- Enhanced the writeToFile tool's response message for clarity on user actions.

* Quick Command (cmd+k) (#1640)

* Quick Command (cmd+k)

* Update custom command modal style

* Improve prompt

* Use submenu for commands

* Fix cursor comment

* Align selected_text variable name

* Fix embedded image wikilinks (#1641)

- Updated the `extractEmbeddedImages` method to accept an optional `sourcePath` parameter for resolving wikilinks.
- Implemented logic to determine the source path from context notes or fallback to the active file.
- Enhanced logging for unresolved images to improve debugging and user feedback.
- Ensured that embedded images are processed correctly based on the new source path handling.

* Fix salient term language issue

* Dedupe source notes (#1642)

* Fix salient term language issue

* Refactor source handling to include path in addition to title and score

- Updated source structures across multiple classes to include a `path` property alongside `title` and `score`.
- Modified methods in `AutonomousAgentChainRunner`, `BaseChainRunner`, and `CopilotPlusChainRunner` to accommodate the new source format.
- Enhanced the `deduplicateSources` function to use `path` as the unique key, falling back to `title` if necessary.
- Adjusted the `SourcesModal` to display paths correctly and ensure links open using the appropriate source path.
- Updated type definitions in `message.ts` to reflect the new source structure.

* Update path handling in source mapping for AutonomousAgentChainRunner

- Modified the source mapping logic to ensure the `path` property defaults to an empty string if not provided, enhancing robustness in source data handling.
- This change improves the consistency of source objects by ensuring that all properties are defined, which aids in downstream processing.

* Bump manifest version to v3

* Fix image (#1643)

* Fix salient term language issue

* Bump manifest version to v3

* Fix image passing

- Updated Chat, ChatManager, MessageRepository, and ChatUIState to include an optional `content` parameter in message handling.
- Adjusted method signatures and message creation logic to accommodate the new parameter, improving flexibility in message processing.

* Fix regenerate (#1644)

* Fix image in agent chain (#1646)

* Add YouTube transcription processing to AutonomousAgentChainRunner (#1647)

- Implemented a new method to extract and process YouTube URLs from user messages.
- Fetch transcriptions using the simpleYoutubeTranscriptionTool and handle errors gracefully.
- Updated conversation messages to include fetched transcriptions for improved context in responses.

* Disable quick command when live-mode is not on (#1648)

* Do not call writeToFileTool again for accepted changes as well (#1652)

* Trigger youtube tool only for urls in prompt not context (#1653)

* Stop calling youtube tool in context notes in agent mode

- Added a new instruction for handling YouTube URLs in the DEFAULT_SYSTEM_PROMPT.
- Removed the direct usage of simpleYoutubeTranscriptionTool in the AutonomousAgentChainRunner, as YouTube transcriptions are now automatically processed.
- Updated parameter naming conventions in modelAdapter for clarity and consistency.
- Cleaned up the simpleYoutubeTranscriptionTool definition by removing unnecessary parameters.

* Add agent loop limit message (#1655)

* Add composer toggle and implement auto-preview (#1651)

* Update composer output format to XML and refactor related components

- Changed the output format for composer instructions from JSON to XML in `constants.ts`.
- Updated `CopilotPlusChainRunner` to utilize the new XML format and adjusted streaming logic accordingly.
- Added a toggle for the composer feature in `ChatInput.tsx` and updated UI elements to reflect this change.
- Removed the `ComposerCodeBlock` component as it is no longer needed with the new implementation.
- Cleaned up unused code and comments in `ChatSingleMessage.tsx` related to the previous composer handling.

* Update src/constants.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/constants.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove redundant tests

* Update doc

* Refactor composer handling and implement ActionBlockStreamer

- Replaced `ComposerBlockStreamer` with `ActionBlockStreamer` to handle `writeToFile` blocks more efficiently.
- Updated `constants.ts` to reflect changes in output format for composer instructions.
- Enhanced `ChatSingleMessage.tsx` to process `writeToFile` sections and integrate collapsible UI elements.
- Removed the deprecated `ComposerBlockStreamer` and its associated tests.
- Improved handling of XML codeblocks and unclosed tags during streaming.

* Fix reported bugs

* Refactor ActionBlockStreamer tests and implementation

- Updated `processChunks` to always push content, including null and empty strings, to the output.
- Adjusted test expectations to reflect changes in output handling for chunks with null content.
- Simplified regex in `findCompleteBlock` method by removing XML format handling.

* Fix reported issue

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix tool description generation (#1659)

* Access tool description from zod.shape when zod.properties is missing.

* Remove the access to schema.properties

* Remove unused print

* Also support schema.propertes

* Implement SimpleTool and remove Langchain tool calling (#1662)

* Make web search tool not overly eager

* Use SimpleTool interface instead of langchain tool call

- Replaced LangChain tool definitions with a new SimpleTool interface for better type safety and validation.
- Updated existing tools (e.g., writeToFile, localSearch, webSearch, and YouTube transcription) to utilize the new createTool function.
- Enhanced parameter extraction from Zod schemas for improved tool description generation.
- Cleaned up related code and documentation to reflect the new tool structure and ensure consistency across the codebase.
- Add tests

* Update ChatManager tests to handle undefined context cases

- Modified test cases in ChatManager to include handling of undefined context parameters.
- Ensured that the tests accurately reflect scenarios where no active file exists and when context is undefined.
- Improved overall test coverage for message processing in various contexts.

* Add validation tests for tool schemas and improve schema definitions

- Introduced comprehensive validation tests for tool schemas in `allTools.validation.test.ts` and `SearchTools.schema.test.ts` to ensure adherence to best practices and proper typing.
- Enhanced `SearchTools.ts` by replacing `z.any()` with specific object schemas for `chatHistory` to improve type safety.
- Added tests for various tool patterns, including metadata validation and schema best practices, ensuring tools are correctly defined and validated against expected interfaces.

* Add back example content for canvas file in ComposerTools schema

* Make web search tool not overly eager

* Add license key in agent mode (#1663)

* Add tool execution tests and implement Plus subscription checks (#1664)

- Introduced unit tests for the `executeSequentialToolCall` function to validate tool execution behavior, including handling of Plus-only tools.
- Enhanced `executeSequentialToolCall` to check for Plus subscription requirements before executing tools.
- Updated `createTool` and `SimpleTool` interfaces to include an `isPlusOnly` flag, indicating tools that require a Copilot Plus subscription.
- Marked existing tools (e.g., webSearch and YouTube transcription) as Plus-only where applicable.

* Add timezone conversion tools and enhance current time functionality (#1665)

* Add timezone conversion tools and enhance current time functionality

- Introduced `convertTimeBetweenTimezonesTool` to allow conversion of time between different timezones.
- Updated `getCurrentTimeTool` to accept an optional timezone parameter, improving its functionality to return time in specified timezones.
- Enhanced documentation and added examples for both tools in the codebase.
- Added comprehensive unit tests for the new timezone conversion functionality to ensure accuracy and reliability.

* Enhance timezone functionality and add unit tests

- Updated `getCurrentTime` and `convertToTimeInfo` functions to utilize Luxon's offset for accurate timezone calculations.
- Adjusted timezone offset handling to reflect correct signs.
- Added unit tests for Tokyo and New York timezone offsets to ensure accuracy in timezone calculations.

* Add unit test for past time conversion in timezone tool

- Introduced a new test case to verify that converting a past time (6:00 AM PT) to Tokyo correctly reflects the same day (January 15) without erroneously adding a day.
- Updated the `convertTimeBetweenTimezones` function to ensure accurate handling of past times without unintended date shifts.

* Enhance timezone handling and update related tests

- Added `convertTimeBetweenTimezonesTool` to facilitate time conversion between different UTC offsets.
- Updated `getCurrentTimeTool` to accept timezone offsets instead of names, improving accuracy and flexibility.
- Enhanced error handling for invalid timezone offset formats and added comprehensive unit tests to validate new functionality.
- Updated existing tests to reflect changes in timezone offset handling and ensure consistent behavior across various scenarios.

* Refactor timezone handling in convertTimeBetweenTimezones function

- Updated the creation of DateTime objects to interpret parsed dates as already being in the source timezone, improving clarity and accuracy in timezone conversions.

* Fix image in chat history (#1666)

* Refactor chat history handling to preserve multimodal content

- Removed the deprecated `extractChatHistory` function and replaced it with direct access to raw history from memory, allowing for the preservation of multimodal content.
- Updated `AutonomousAgentChainRunner`, `BaseChainRunner`, and `CopilotPlusChainRunner` to utilize `BaseMessage` objects for chat history, ensuring that both user and AI messages maintain their original structure and content.
- Enhanced message processing to accommodate multimodal inputs, storing them appropriately for later use.

* Refactor chat history handling to improve message processing

- Introduced `addChatHistoryToMessages` utility to streamline the addition of chat history, ensuring safe handling of various message formats.
- Updated `AutonomousAgentChainRunner` and `CopilotPlusChainRunner` to utilize the new utility, enhancing clarity and maintainability of the code.
- Removed deprecated manual processing of chat history to improve code efficiency and readability.

* Refactor chat history processing to enhance multimodal support

- Removed the deprecated `extractChatHistory` function and replaced it with `processRawChatHistory` to ensure consistent handling of chat history.
- Introduced `processedMessagesToTextOnly` function to extract text-only content from multimodal messages, improving clarity for question condensing.
- Updated `CopilotPlusChainRunner` to utilize the new processing functions, ensuring that both LLM and question condensing use the same data format.
- Added comprehensive unit tests for `processedMessagesToTextOnly` to validate its functionality across various message formats.

* Refactor memory management in BaseChainRunner to enhance context saving

- Removed the creation of `HumanMessage` and `AIMessage` objects for chat history, simplifying the process.
- Implemented `saveContext` for atomic operations, ensuring proper memory management while preserving input and output data.
- Noted that LangChain's memory now expects text content, which limits the saving of multimodal content.

* Remove multimodal content storage from userMessage in AutonomousAgentChainRunner and CopilotPlusChainRunner to streamline message processing. This change aligns with recent refactors to enhance memory management and improve clarity in handling chat history.

* Enhance ChatInput component with autonomous agent toggle button (#1669)

- Added a toggle for the autonomous agent feature, allowing users to enable or disable it within the ChatInput component.
- Updated the state management to synchronize the autonomous agent toggle with user settings.
- Introduced a button for toggling the autonomous agent mode, which is only visible in Copilot Plus mode.
- Refactored tool call logic to accommodate the new autonomous agent toggle, ensuring proper handling of tool calls based on the toggle state.

* Implement custom topic in conversation history (#1670)

* Refactor LoadChatHistoryModal and ChatPersistenceManager for enhanced topic handling

- Updated LoadChatHistoryModal to prioritize custom topics from file frontmatter, falling back to filename extraction if not present.
- Enhanced ChatPersistenceManager to generate AI topics for new chat files and preserve existing topics when updating files.
- Introduced new methods for finding files by epoch and generating AI topics based on conversation content, improving chat file management and organization.

* Refactor LoadChatHistoryModal and ChatPersistenceManager for improved topic handling

- Updated LoadChatHistoryModal to ensure custom topics are trimmed and validated before use.
- Enhanced ChatPersistenceManager to utilize reduce for efficient conversation summary generation, introducing constants for message and character limits to improve readability and maintainability.

* Implement tool call UI banner (#1671)

* Only add tool call to the user message when agent is off

- Updated the tool call logic to only add tool calls when the autonomous agent toggle is off, ensuring that the autonomous agent manages all tools internally when enabled.
- Improved code clarity by adding comments to explain the new logic.

* Implement tool call collapsible UI element

- Introduced a new utility for managing tool call markers, allowing for structured display and tracking of tool calls during execution.
- Updated the AutonomousAgentChainRunner to utilize the new tool call markers, improving clarity in the streaming response and tool execution process.
- Added a ToolCallBanner component for better visualization of tool calls in the chat interface, including execution status and results.
- Refactored ChatSingleMessage to handle tool call updates dynamically, ensuring that the UI reflects the current state of tool calls.
- Enhanced CSS for tool call containers and added animations for improved user experience.

* Fix disappearing tool call in final response

* Implement tool call result formatting

- Updated ChatSingleMessage to use a stable ID for message roots, enhancing memory management and preventing leaks.
- Implemented cleanup logic for old message roots to optimize performance.
- Introduced ToolResultFormatter to format tool results for better display in the UI, ensuring user-friendly output for various tool types.
- Enhanced ToolCallBanner to utilize the new formatter, improving the presentation of tool call results in the chat interface.

* Add YouTube transcription tool in agent and avoid mistrigger from context

- Replaced the deprecated simpleYoutubeTranscriptionTool with a new youtubeTranscriptionTool that automatically extracts YouTube URLs from user messages.
- Removed the processYouTubeUrls method to streamline the handling of YouTube transcriptions.
- Updated tool execution logic to pass the original user message to tools that require it, enhancing flexibility in tool usage.
- Improved the ToolResultFormatter to support multi-URL responses and provide better error handling for transcription failures.
- Enhanced documentation for YouTube transcription usage in the codebase.

* Enhance ToolCallBanner and ToolResultFormatter for improved user experience

- Added animation constants to ToolCallBanner for better visual feedback.
- Updated ToolResultFormatter to robustly handle JSON parsing, improving error handling for both single objects and arrays.
- Implemented input validation in YouTube transcription tool to prevent excessive input lengths and ensure proper data types.

* Refactor tool execution messages and enhance JSON parsing in ToolResultFormatter

- Updated the confirmation message for the writeToFile tool to be more concise.
- Added a private method in ToolResultFormatter to robustly parse JSON strings, improving error handling and ensuring consistent results.
- Streamlined the handling of search results by separating JSON parsing and regex extraction logic in ToolResultFormatter.
- Enforced required user message content in the YouTube transcription tool to prevent potential errors.

* Truncate long tool results for compact memory (#1672)

* Truncate long tool results for compact memory

- Introduced `processToolResults` utility to format tool results for different contexts, allowing for both truncated and full results.
- Updated `AutonomousAgentChainRunner` to utilize the new utility for improved memory management and clarity in tool result presentation.
- Added unit tests for `toolResultUtils` to ensure proper functionality of truncation and formatting methods, enhancing reliability and maintainability of tool result processing.

* Enhance tool results handling in AutonomousAgentChainRunner

- Added a conditional check to ensure that only non-null tool results are pushed to the llmFormattedMessages array, improving the robustness of message formatting and preventing potential errors from undefined values.

* Add Agent settings section (#1676)

* Add autonomous agent settings and tool configurations

- Introduced settings for enabling the autonomous agent mode, including maximum iterations and available tools.
- Updated the CopilotPlusSettings component to allow users to toggle tools like local search, web search, and YouTube transcription.
- Enhanced the model adapter to utilize the new settings for tool management.

* Enhance tool management by adding always available tools and updating tool usage guidelines in autonomous agent mode

* Make filetree tool always available

* Refactor tool management system to use a centralized ToolRegistry, enabling dynamic tool registration and configuration. Update settings structure to support enabled tool IDs and enhance UI for tool selection in the Copilot settings.

* Initialize built-in tools with vault access and improve tool registration logic

* Enhance documentation for initializeBuiltinTools function to clarify tool registration process and dynamic filtering of user-enabled tools.

* fix: Fix the shouldIndexFile method to return all files when inclusions is empty in project mode. (#1667)

* feat: Display all category items by default in the context-manage-modal. (#1657)

* Remove escapingXML for context data (#1678)

* Add apply command (#1677)

* Implement ReplaceInFile tool (#1661)

* Relevant note improvements (#1681)

* Change message scrolling behavior (#1680)

* Fix timetools and tests (#1683)

* Refactor time tool descriptions for clarity and examples

* Update XML escaping tests to reflect correct behavior and remove unnecessary settings mock

* Strip partial tool call tag (#1682)

* Strip partial tool call tag

* Show a placeholder message

* Remove perserveToolIndicators option

* Add tool dynamic prompt (#1679)

* feat: Enhance tool execution error messages and update custom prompt instructions for built-in tools

* feat: Add unit tests for ModelAdapter and enhance tool instructions for time-based queries

* feat: Implement detailed formatting for replaceInFile tool results

* feat: Enhance GPT model instructions and refine replaceInFile tool usage guidelines

* feat: Expand documentation on schema descriptions and custom instructions for tool usage

* feat: Refine tool documentation by clarifying schema descriptions and custom prompt instructions

* feat: Update GitHub Actions workflow to run on all pull requests regardless of target branch

* Fix tests

* feat: Update integration tests to validate writeToFile blocks and enhance replaceInFile usage example

* Enhance image extraction logic to support both wiki-style and markdown image syntax (#1685)

* Turn off agent when in Projects mode (#1686)

* Remove think and action blocks from copy and insert (#1687)

* Remove think and action blocks from copy, and fix composer for thinking models

* Setup AgentTest with correct prompt and tools

* Clean up

* Add a few more tests and validate call order

---------

Co-authored-by: Logan Yang <logancyang@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Zero Liu <zero@lumos.com>
Co-authored-by: Emt-lin <41323133+Emt-lin@users.noreply.github.com>
2025-08-06 13:42:07 -07:00
Logan Yang
c1c95ca36d
Update version to 2.9.4 in manifest.json (#1691) 2025-08-04 12:06:15 -07:00
Logan Yang
f652cf0fb3
Remove beta labels from Copilot Plus (#1690)
- Remove "(beta)" from Copilot Plus in all UI components
- Update PlusSettings.tsx heading and description
- Update BasicSettings.tsx ChainType label
- Update ChatControls.tsx dropdown menu items

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-04 10:18:03 -07:00
Logan Yang
bc2d7f660a
Implement autonomous agent (#1689)
* Implement autonomous agent mode and new message architecture

- Implement autonomous agent mode with sequential thinking capabilities
- Migrate from sharedState to new clean message management architecture
- Add ChatManager, MessageRepository, and ContextManager for better separation of concerns
- Implement project-based chat isolation with separate message repositories per project
- Add ChatPersistenceManager for saving/loading chat history
- Refactor chain runner architecture with specialized runners (Autonomous, Project, VaultQA)
- Add XML tool call handling for better compatibility across LLM providers
- Introduce vault and web search toggles in settings
- Enhance model adapters with explicit tool usage instructions
- Add comprehensive test coverage for new architecture components
- Update UI components to use new ChatUIState instead of SharedState

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* Enhance context processing and documentation

- Added detailed XML structure for context notes, including metadata (creation and modification times) for notes and selected text.
- Introduced new tests for message lifecycle and XML tag formatting to ensure proper context handling.
- Created TODO.md to track technical debt and future improvements related to context processing and file handling.
- Updated CLAUDE.md to reference the new TODO.md for technical debt awareness.

This commit improves the clarity and functionality of context processing in messages, ensuring rich context is maintained throughout the message lifecycle.

* Fix composer instruction in new message implementation

* Add writeToFile tool to agent (#1632)

* Add writeToFile tool call to agent

* Update src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/tools/ComposerTools.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove unuseful comment

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix tests

* Enhance context processing with new XML tags for notes and selected text (#1633)

* Enhance context processing with new XML tags for notes and selected text

- Introduced new constants for XML tags: SELECTED_TEXT_TAG, VARIABLE_TAG, VARIABLE_NOTE_TAG, EMBEDDED_PDF_TAG, VAULT_NOTE_TAG, and RETRIEVED_DOCUMENT_TAG.
- Updated context processing to format embedded PDFs and notes with XML structure.
- Modified prompt processing to include XML tags for selected text and variables, improving clarity and structure in generated outputs.
- Enhanced tests to validate the new XML formatting in processed prompts.

* Implement XML escaping utility functions and integrate into context processing

- Added `escapeXml` and `escapeXmlAttribute` functions to handle XML special character escaping, enhancing security against XML injection.
- Updated context processing to utilize these functions for escaping note titles, paths, and content, ensuring proper XML formatting.
- Enhanced tests to validate XML escaping functionality across various scenarios, including selected text and variable names.
- Introduced new test files for XML utility functions to ensure comprehensive coverage and reliability.

* Refactor XML utility functions and update imports

- Moved `escapeXml` and `escapeXmlAttribute` functions from `utils/xmlUtils` to `LLMProviders/chainRunner/utils/xmlParsing`, enhancing modularity.
- Updated all relevant imports across the codebase to reflect the new location of XML utility functions.
- Removed obsolete `xmlUtils` file and its associated tests, consolidating XML handling in a single module for better maintainability.

* Add message creation callback in ChatManager for immediate UI updates (#1634)

- Introduced a new method `setOnMessageCreatedCallback` in ChatManager to allow setting a callback that triggers when a message is created.
- Updated ChatUIState to utilize this callback for notifying listeners, ensuring the UI updates immediately upon message creation.
- This enhancement improves the responsiveness of the chat interface by synchronizing message creation events with UI state updates.

* Enhance TimeTools tests for invalid expressions

- Added setup to mock console.warn in tests for invalid time expressions.
- Verified that the appropriate warning message is logged when parsing fails.
- Improved test coverage for handling various invalid time inputs.

* Refactor local search handling and restore sources display

- Unified tool handling in CopilotPlusChainRunner to fix websearch results being ignored when localSearch has results
- All tool outputs now go through the same prepareEnhancedUserMessage method
- Preserved QA format and instruction when only localSearch is used
- Added proper validation for localSearch results (non-empty documents array)
- Maintained time expression support for temporal queries
- Fixed source tracking and display in ChatUIState
- Enhanced error handling for JSON parsing of search results

* Enhance AutonomousAgentChainRunner to track tool calls and results (#1639)

- Introduced a new property to store LLM-formatted messages for memory management.
- Reset LLM messages at the start of each run to ensure fresh context.
- Updated response handling to include LLM-formatted outputs, enhancing memory context for future iterations.
- Modified the base class to accept an optional LLM-formatted output parameter for improved context saving.
- Enhanced the writeToFile tool's response message for clarity on user actions.

* Quick Command (cmd+k) (#1640)

* Quick Command (cmd+k)

* Update custom command modal style

* Improve prompt

* Use submenu for commands

* Fix cursor comment

* Align selected_text variable name

* Fix embedded image wikilinks (#1641)

- Updated the `extractEmbeddedImages` method to accept an optional `sourcePath` parameter for resolving wikilinks.
- Implemented logic to determine the source path from context notes or fallback to the active file.
- Enhanced logging for unresolved images to improve debugging and user feedback.
- Ensured that embedded images are processed correctly based on the new source path handling.

* Fix salient term language issue

* Dedupe source notes (#1642)

* Fix salient term language issue

* Refactor source handling to include path in addition to title and score

- Updated source structures across multiple classes to include a `path` property alongside `title` and `score`.
- Modified methods in `AutonomousAgentChainRunner`, `BaseChainRunner`, and `CopilotPlusChainRunner` to accommodate the new source format.
- Enhanced the `deduplicateSources` function to use `path` as the unique key, falling back to `title` if necessary.
- Adjusted the `SourcesModal` to display paths correctly and ensure links open using the appropriate source path.
- Updated type definitions in `message.ts` to reflect the new source structure.

* Update path handling in source mapping for AutonomousAgentChainRunner

- Modified the source mapping logic to ensure the `path` property defaults to an empty string if not provided, enhancing robustness in source data handling.
- This change improves the consistency of source objects by ensuring that all properties are defined, which aids in downstream processing.

* Bump manifest version to v3

* Fix image (#1643)

* Fix salient term language issue

* Bump manifest version to v3

* Fix image passing

- Updated Chat, ChatManager, MessageRepository, and ChatUIState to include an optional `content` parameter in message handling.
- Adjusted method signatures and message creation logic to accommodate the new parameter, improving flexibility in message processing.

* Fix regenerate (#1644)

* Fix image in agent chain (#1646)

* Add YouTube transcription processing to AutonomousAgentChainRunner (#1647)

- Implemented a new method to extract and process YouTube URLs from user messages.
- Fetch transcriptions using the simpleYoutubeTranscriptionTool and handle errors gracefully.
- Updated conversation messages to include fetched transcriptions for improved context in responses.

* Disable quick command when live-mode is not on (#1648)

* Do not call writeToFileTool again for accepted changes as well (#1652)

* Trigger youtube tool only for urls in prompt not context (#1653)

* Stop calling youtube tool in context notes in agent mode

- Added a new instruction for handling YouTube URLs in the DEFAULT_SYSTEM_PROMPT.
- Removed the direct usage of simpleYoutubeTranscriptionTool in the AutonomousAgentChainRunner, as YouTube transcriptions are now automatically processed.
- Updated parameter naming conventions in modelAdapter for clarity and consistency.
- Cleaned up the simpleYoutubeTranscriptionTool definition by removing unnecessary parameters.

* Add agent loop limit message (#1655)

* Add composer toggle and implement auto-preview (#1651)

* Update composer output format to XML and refactor related components

- Changed the output format for composer instructions from JSON to XML in `constants.ts`.
- Updated `CopilotPlusChainRunner` to utilize the new XML format and adjusted streaming logic accordingly.
- Added a toggle for the composer feature in `ChatInput.tsx` and updated UI elements to reflect this change.
- Removed the `ComposerCodeBlock` component as it is no longer needed with the new implementation.
- Cleaned up unused code and comments in `ChatSingleMessage.tsx` related to the previous composer handling.

* Update src/constants.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/constants.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove redundant tests

* Update doc

* Refactor composer handling and implement ActionBlockStreamer

- Replaced `ComposerBlockStreamer` with `ActionBlockStreamer` to handle `writeToFile` blocks more efficiently.
- Updated `constants.ts` to reflect changes in output format for composer instructions.
- Enhanced `ChatSingleMessage.tsx` to process `writeToFile` sections and integrate collapsible UI elements.
- Removed the deprecated `ComposerBlockStreamer` and its associated tests.
- Improved handling of XML codeblocks and unclosed tags during streaming.

* Fix reported bugs

* Refactor ActionBlockStreamer tests and implementation

- Updated `processChunks` to always push content, including null and empty strings, to the output.
- Adjusted test expectations to reflect changes in output handling for chunks with null content.
- Simplified regex in `findCompleteBlock` method by removing XML format handling.

* Fix reported issue

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix tool description generation (#1659)

* Access tool description from zod.shape when zod.properties is missing.

* Remove the access to schema.properties

* Remove unused print

* Also support schema.propertes

* Implement SimpleTool and remove Langchain tool calling (#1662)

* Make web search tool not overly eager

* Use SimpleTool interface instead of langchain tool call

- Replaced LangChain tool definitions with a new SimpleTool interface for better type safety and validation.
- Updated existing tools (e.g., writeToFile, localSearch, webSearch, and YouTube transcription) to utilize the new createTool function.
- Enhanced parameter extraction from Zod schemas for improved tool description generation.
- Cleaned up related code and documentation to reflect the new tool structure and ensure consistency across the codebase.
- Add tests

* Update ChatManager tests to handle undefined context cases

- Modified test cases in ChatManager to include handling of undefined context parameters.
- Ensured that the tests accurately reflect scenarios where no active file exists and when context is undefined.
- Improved overall test coverage for message processing in various contexts.

* Add validation tests for tool schemas and improve schema definitions

- Introduced comprehensive validation tests for tool schemas in `allTools.validation.test.ts` and `SearchTools.schema.test.ts` to ensure adherence to best practices and proper typing.
- Enhanced `SearchTools.ts` by replacing `z.any()` with specific object schemas for `chatHistory` to improve type safety.
- Added tests for various tool patterns, including metadata validation and schema best practices, ensuring tools are correctly defined and validated against expected interfaces.

* Add back example content for canvas file in ComposerTools schema

* Make web search tool not overly eager

* Add license key in agent mode (#1663)

* Add tool execution tests and implement Plus subscription checks (#1664)

- Introduced unit tests for the `executeSequentialToolCall` function to validate tool execution behavior, including handling of Plus-only tools.
- Enhanced `executeSequentialToolCall` to check for Plus subscription requirements before executing tools.
- Updated `createTool` and `SimpleTool` interfaces to include an `isPlusOnly` flag, indicating tools that require a Copilot Plus subscription.
- Marked existing tools (e.g., webSearch and YouTube transcription) as Plus-only where applicable.

* Add timezone conversion tools and enhance current time functionality (#1665)

* Add timezone conversion tools and enhance current time functionality

- Introduced `convertTimeBetweenTimezonesTool` to allow conversion of time between different timezones.
- Updated `getCurrentTimeTool` to accept an optional timezone parameter, improving its functionality to return time in specified timezones.
- Enhanced documentation and added examples for both tools in the codebase.
- Added comprehensive unit tests for the new timezone conversion functionality to ensure accuracy and reliability.

* Enhance timezone functionality and add unit tests

- Updated `getCurrentTime` and `convertToTimeInfo` functions to utilize Luxon's offset for accurate timezone calculations.
- Adjusted timezone offset handling to reflect correct signs.
- Added unit tests for Tokyo and New York timezone offsets to ensure accuracy in timezone calculations.

* Add unit test for past time conversion in timezone tool

- Introduced a new test case to verify that converting a past time (6:00 AM PT) to Tokyo correctly reflects the same day (January 15) without erroneously adding a day.
- Updated the `convertTimeBetweenTimezones` function to ensure accurate handling of past times without unintended date shifts.

* Enhance timezone handling and update related tests

- Added `convertTimeBetweenTimezonesTool` to facilitate time conversion between different UTC offsets.
- Updated `getCurrentTimeTool` to accept timezone offsets instead of names, improving accuracy and flexibility.
- Enhanced error handling for invalid timezone offset formats and added comprehensive unit tests to validate new functionality.
- Updated existing tests to reflect changes in timezone offset handling and ensure consistent behavior across various scenarios.

* Refactor timezone handling in convertTimeBetweenTimezones function

- Updated the creation of DateTime objects to interpret parsed dates as already being in the source timezone, improving clarity and accuracy in timezone conversions.

* Fix image in chat history (#1666)

* Refactor chat history handling to preserve multimodal content

- Removed the deprecated `extractChatHistory` function and replaced it with direct access to raw history from memory, allowing for the preservation of multimodal content.
- Updated `AutonomousAgentChainRunner`, `BaseChainRunner`, and `CopilotPlusChainRunner` to utilize `BaseMessage` objects for chat history, ensuring that both user and AI messages maintain their original structure and content.
- Enhanced message processing to accommodate multimodal inputs, storing them appropriately for later use.

* Refactor chat history handling to improve message processing

- Introduced `addChatHistoryToMessages` utility to streamline the addition of chat history, ensuring safe handling of various message formats.
- Updated `AutonomousAgentChainRunner` and `CopilotPlusChainRunner` to utilize the new utility, enhancing clarity and maintainability of the code.
- Removed deprecated manual processing of chat history to improve code efficiency and readability.

* Refactor chat history processing to enhance multimodal support

- Removed the deprecated `extractChatHistory` function and replaced it with `processRawChatHistory` to ensure consistent handling of chat history.
- Introduced `processedMessagesToTextOnly` function to extract text-only content from multimodal messages, improving clarity for question condensing.
- Updated `CopilotPlusChainRunner` to utilize the new processing functions, ensuring that both LLM and question condensing use the same data format.
- Added comprehensive unit tests for `processedMessagesToTextOnly` to validate its functionality across various message formats.

* Refactor memory management in BaseChainRunner to enhance context saving

- Removed the creation of `HumanMessage` and `AIMessage` objects for chat history, simplifying the process.
- Implemented `saveContext` for atomic operations, ensuring proper memory management while preserving input and output data.
- Noted that LangChain's memory now expects text content, which limits the saving of multimodal content.

* Remove multimodal content storage from userMessage in AutonomousAgentChainRunner and CopilotPlusChainRunner to streamline message processing. This change aligns with recent refactors to enhance memory management and improve clarity in handling chat history.

* Enhance ChatInput component with autonomous agent toggle button (#1669)

- Added a toggle for the autonomous agent feature, allowing users to enable or disable it within the ChatInput component.
- Updated the state management to synchronize the autonomous agent toggle with user settings.
- Introduced a button for toggling the autonomous agent mode, which is only visible in Copilot Plus mode.
- Refactored tool call logic to accommodate the new autonomous agent toggle, ensuring proper handling of tool calls based on the toggle state.

* Implement custom topic in conversation history (#1670)

* Refactor LoadChatHistoryModal and ChatPersistenceManager for enhanced topic handling

- Updated LoadChatHistoryModal to prioritize custom topics from file frontmatter, falling back to filename extraction if not present.
- Enhanced ChatPersistenceManager to generate AI topics for new chat files and preserve existing topics when updating files.
- Introduced new methods for finding files by epoch and generating AI topics based on conversation content, improving chat file management and organization.

* Refactor LoadChatHistoryModal and ChatPersistenceManager for improved topic handling

- Updated LoadChatHistoryModal to ensure custom topics are trimmed and validated before use.
- Enhanced ChatPersistenceManager to utilize reduce for efficient conversation summary generation, introducing constants for message and character limits to improve readability and maintainability.

* Implement tool call UI banner (#1671)

* Only add tool call to the user message when agent is off

- Updated the tool call logic to only add tool calls when the autonomous agent toggle is off, ensuring that the autonomous agent manages all tools internally when enabled.
- Improved code clarity by adding comments to explain the new logic.

* Implement tool call collapsible UI element

- Introduced a new utility for managing tool call markers, allowing for structured display and tracking of tool calls during execution.
- Updated the AutonomousAgentChainRunner to utilize the new tool call markers, improving clarity in the streaming response and tool execution process.
- Added a ToolCallBanner component for better visualization of tool calls in the chat interface, including execution status and results.
- Refactored ChatSingleMessage to handle tool call updates dynamically, ensuring that the UI reflects the current state of tool calls.
- Enhanced CSS for tool call containers and added animations for improved user experience.

* Fix disappearing tool call in final response

* Implement tool call result formatting

- Updated ChatSingleMessage to use a stable ID for message roots, enhancing memory management and preventing leaks.
- Implemented cleanup logic for old message roots to optimize performance.
- Introduced ToolResultFormatter to format tool results for better display in the UI, ensuring user-friendly output for various tool types.
- Enhanced ToolCallBanner to utilize the new formatter, improving the presentation of tool call results in the chat interface.

* Add YouTube transcription tool in agent and avoid mistrigger from context

- Replaced the deprecated simpleYoutubeTranscriptionTool with a new youtubeTranscriptionTool that automatically extracts YouTube URLs from user messages.
- Removed the processYouTubeUrls method to streamline the handling of YouTube transcriptions.
- Updated tool execution logic to pass the original user message to tools that require it, enhancing flexibility in tool usage.
- Improved the ToolResultFormatter to support multi-URL responses and provide better error handling for transcription failures.
- Enhanced documentation for YouTube transcription usage in the codebase.

* Enhance ToolCallBanner and ToolResultFormatter for improved user experience

- Added animation constants to ToolCallBanner for better visual feedback.
- Updated ToolResultFormatter to robustly handle JSON parsing, improving error handling for both single objects and arrays.
- Implemented input validation in YouTube transcription tool to prevent excessive input lengths and ensure proper data types.

* Refactor tool execution messages and enhance JSON parsing in ToolResultFormatter

- Updated the confirmation message for the writeToFile tool to be more concise.
- Added a private method in ToolResultFormatter to robustly parse JSON strings, improving error handling and ensuring consistent results.
- Streamlined the handling of search results by separating JSON parsing and regex extraction logic in ToolResultFormatter.
- Enforced required user message content in the YouTube transcription tool to prevent potential errors.

* Truncate long tool results for compact memory (#1672)

* Truncate long tool results for compact memory

- Introduced `processToolResults` utility to format tool results for different contexts, allowing for both truncated and full results.
- Updated `AutonomousAgentChainRunner` to utilize the new utility for improved memory management and clarity in tool result presentation.
- Added unit tests for `toolResultUtils` to ensure proper functionality of truncation and formatting methods, enhancing reliability and maintainability of tool result processing.

* Enhance tool results handling in AutonomousAgentChainRunner

- Added a conditional check to ensure that only non-null tool results are pushed to the llmFormattedMessages array, improving the robustness of message formatting and preventing potential errors from undefined values.

* Add Agent settings section (#1676)

* Add autonomous agent settings and tool configurations

- Introduced settings for enabling the autonomous agent mode, including maximum iterations and available tools.
- Updated the CopilotPlusSettings component to allow users to toggle tools like local search, web search, and YouTube transcription.
- Enhanced the model adapter to utilize the new settings for tool management.

* Enhance tool management by adding always available tools and updating tool usage guidelines in autonomous agent mode

* Make filetree tool always available

* Refactor tool management system to use a centralized ToolRegistry, enabling dynamic tool registration and configuration. Update settings structure to support enabled tool IDs and enhance UI for tool selection in the Copilot settings.

* Initialize built-in tools with vault access and improve tool registration logic

* Enhance documentation for initializeBuiltinTools function to clarify tool registration process and dynamic filtering of user-enabled tools.

* fix: Fix the shouldIndexFile method to return all files when inclusions is empty in project mode. (#1667)

* feat: Display all category items by default in the context-manage-modal. (#1657)

* Remove escapingXML for context data (#1678)

* Add apply command (#1677)

* Implement ReplaceInFile tool (#1661)

* Relevant note improvements (#1681)

* Change message scrolling behavior (#1680)

* Fix timetools and tests (#1683)

* Refactor time tool descriptions for clarity and examples

* Update XML escaping tests to reflect correct behavior and remove unnecessary settings mock

* Strip partial tool call tag (#1682)

* Strip partial tool call tag

* Show a placeholder message

* Remove perserveToolIndicators option

* Add tool dynamic prompt (#1679)

* feat: Enhance tool execution error messages and update custom prompt instructions for built-in tools

* feat: Add unit tests for ModelAdapter and enhance tool instructions for time-based queries

* feat: Implement detailed formatting for replaceInFile tool results

* feat: Enhance GPT model instructions and refine replaceInFile tool usage guidelines

* feat: Expand documentation on schema descriptions and custom instructions for tool usage

* feat: Refine tool documentation by clarifying schema descriptions and custom prompt instructions

* feat: Update GitHub Actions workflow to run on all pull requests regardless of target branch

* Fix tests

* feat: Update integration tests to validate writeToFile blocks and enhance replaceInFile usage example

* Enhance image extraction logic to support both wiki-style and markdown image syntax (#1685)

* Turn off agent when in Projects mode (#1686)

* Remove think and action blocks from copy and insert (#1687)

* Remove think and action blocks from copy, and fix composer for thinking models

* Refactor ToolSettingsSection layout for improved readability and consistency

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: wenzhengjiang <jwzh.hi@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Zero Liu <zero@lumos.com>
Co-authored-by: Emt-lin <41323133+Emt-lin@users.noreply.github.com>
2025-08-04 09:51:18 -07:00
Logan Yang
b79b8d0a38
Migrate chat messages (#1629)
* Migrate from sharedState to new chat message system

- Replaced SharedState with a new ChatManager and ChatUIState architecture to streamline message processing and UI integration.
- Updated message imports to utilize the new types from "@/types/message".
- Introduced unique message IDs for better tracking and management.
- Enhanced the Chat component to support pending messages and improved message deletion logic.
- Removed deprecated shared state references and cleaned up related code for clarity and maintainability.

Enhance message management architecture and context processing

- Introduced a new MessageRepository for single source of truth in message storage, eliminating dual-array synchronization.
- Updated ChatManager to coordinate message operations and context processing, ensuring fresh context during edits.
- Refactored ChatUIState for clean UI state management, delegating business logic to ChatManager.
- Added comprehensive testing for message handling and context processing to prevent bugs and ensure reliability.
- Removed legacy SharedState references and improved overall architecture for clarity and maintainability.

Update claude.md

Remove conditional project tag from chat content rendering in Chat component

Ensure project chat isolation

- Introduced a method to retrieve the current project ID in ProjectManager.
- Enhanced ChatManager to manage multiple project-specific message repositories, allowing for dynamic switching based on the current project.
- Implemented logic to load existing messages from the ProjectManager's cache when creating new message repositories.
- Updated message handling methods in ChatManager to utilize the current project's message repository, ensuring context consistency across project switches.

* Fix message isolation between projects and non-project

- Introduced ChatManager and ChatUIState to streamline message processing and UI updates.
- Removed legacy message caching from ProjectManager, delegating message persistence to ChatManager.
- Updated CopilotView and Chat components to utilize the new architecture, enhancing message loading and project switching.
- Cleaned up deprecated code related to pending messages and shared state for improved maintainability.

* Implement a new chat persistence module

- Introduced ChatPersistenceManager to handle saving and loading chat history in markdown format, ensuring project-aware file naming.
- Updated ChatManager to utilize the new persistence functionality, streamlining message loading and saving processes.
- Refactored ChatUIState to support asynchronous loading and saving of messages, improving UI responsiveness.
- Added comprehensive tests for ChatPersistenceManager to ensure reliability in chat content formatting and parsing.
- Updated architecture documentation to reflect the new message management structure and flow.

* Update message management architecture doc

- Removed detailed architecture diagrams from CLAUDE.md and linked to MESSAGE_ARCHITECTURE.md for clarity.
- Updated MESSAGE_ARCHITECTURE.md to include project isolation features, enhancing the message management system.
- Improved documentation on message flow and project-specific repositories, ensuring seamless context switching.
- Streamlined the explanation of core classes and their interactions within the new architecture.

* Fix test

* Use llm message for commands to ensure context access

* Update docs
2025-07-17 01:41:53 -07:00
Zero Liu
b0f7971a4c
Custom command settings improvement (#1611)
* Auto add frontmatter when adding or renaming custom prompt files

* Make custom command enable slash and right click command by default

* Make add command button open the command edit dialog

* More improvements

* Fix test

* Fix cursor reported errors

* Add 'create new custom command' command back

* Fix rename race condition

* Fix more cursor errors

* Fix race conditions
2025-07-14 23:19:11 -07:00
Logan Yang
607b3c0907
2.9.4 (#1622) 2025-07-10 16:50:40 -07:00
Logan Yang
c1dbc965ed
Exclude copilot folders from indexing by default (#1621) 2025-07-10 16:25:31 -07:00
Logan Yang
c07ab31685
Implement file type validation for context in ChatInput and BaseNoteModal components (#1620)
- Added `isAllowedFileForContext` utility function to check if a file is of allowed types (markdown, PDF, or canvas).
- Updated `ChatInput` to use this function for setting the current active note.
- Refactored `BaseNoteModal` to utilize the new utility for filtering files and managing the active note state.
2025-07-10 15:40:45 -07:00
Logan Yang
592b7602d4
Fix copilot-plus-small (#1619) 2025-07-10 00:22:31 -07:00
Wenzheng Jiang
c345ddda60
Fix composer canvas codeblock (#1617) 2025-07-09 13:48:31 -07:00
Logan Yang
e06ace16b1
2.9.3 (#1605) 2025-07-03 12:05:25 -07:00
Logan Yang
2625fa949f
Add Projects rate limit UI change (#1603) 2025-07-03 00:25:34 -07:00
Logan Yang
831ae77354
Update file upload guidelines and rate limit information (#1602) 2025-07-02 21:55:31 -07:00
Logan Yang
1f1703906f
Fix slash trigger (#1600) 2025-07-02 15:54:54 -07:00
Logan Yang
aae4548f3b
Fix default model reset (#1599) 2025-07-02 15:42:06 -07:00
Logan Yang
276dd992d8
2.9.2 (#1595) 2025-07-01 15:49:45 -07:00
Logan Yang
ccf2c3f34a
Implement auto youtube tool (#1594) 2025-07-01 15:32:00 -07:00
Mathieu Colmon
ecf8eeff53
feat(chat): Implement autosave for chat after messages and responses (#1582)
* feat(chat): Implement autosave for chat after messages and responses

Adds autosave functionality to the chat component, saving the chat after every user message and AI response if the autosave setting is enabled. Updates the autosave chat setting description for clarity.

* fix(chat): Use sharedState for visible messages in Chat

Updated the Chat component to filter visible messages using sharedState.getMessages() instead of chatHistory, ensuring the latest messages are used. Also removed a redundant notice when updating an existing note.

* fix(chat): Make autosave non-blocking

The handleSaveAsNote function is now called without await in autosaveChat blocks.

---------

Co-authored-by: Logan Yang <logancyang@gmail.com>
2025-07-01 13:18:27 -07:00
Mathieu Colmon
50b3389344
Add Escape key support to cancel message editing (#1589)
Pressing Escape while editing a message now cancels the edit and restores the original message. Also removed onBlur save behavior to prevent accidental saves when focus is lost.
2025-07-01 12:39:13 -07:00
Logan Yang
73bf983511
Fix auto index trigger (#1593) 2025-07-01 12:30:51 -07:00
Logan Yang
ca6bfc1e8f
fix: Disable autocomplete by default and update activation logic based on Plus user status in AutocompleteService (#1592) 2025-06-30 16:03:30 -07:00
619 changed files with 133393 additions and 24904 deletions

View file

@ -0,0 +1,85 @@
---
name: code-reviewer
description: Use this agent when you need a senior software engineer's perspective on code quality, focusing on simplification, minimalism, and elegance. This agent should be invoked after writing or modifying code to ensure it follows best practices and is as clean as possible. Examples:\n\n<example>\nContext: The user has just written a new function or modified existing code and wants it reviewed for simplicity and elegance.\nuser: "I've implemented a function to process user data"\nassistant: "I've written the function. Now let me use the code-elegance-reviewer agent to review it for best practices and potential simplifications."\n<commentary>\nSince new code was written, use the Task tool to launch the code-elegance-reviewer agent to analyze it for improvements.\n</commentary>\n</example>\n\n<example>\nContext: The user has completed a feature implementation and wants a code review.\nuser: "I've finished implementing the authentication logic"\nassistant: "Great! Let me invoke the code-elegance-reviewer agent to review your authentication implementation for elegance and best practices."\n<commentary>\nThe user has completed code changes, so use the code-elegance-reviewer agent to provide senior-level review feedback.\n</commentary>\n</example>\n\n<example>\nContext: The assistant has just generated code in response to a user request.\nassistant: "Here's the implementation you requested: [code]. Now let me review this with the code-elegance-reviewer agent to ensure it meets best practices."\n<commentary>\nAfter generating code, proactively use the code-elegance-reviewer agent to review and suggest improvements.\n</commentary>\n</example>
model: sonnet
color: cyan
---
You are a senior software engineer with 15+ years of experience across multiple programming paradigms and languages. Your expertise lies in writing clean, maintainable, and elegant code that stands the test of time. You have a keen eye for unnecessary complexity and a talent for simplification without sacrificing functionality.
Your primary mission is to review code changes with these core principles:
**Review Philosophy:**
- Simplicity is the ultimate sophistication - every line should justify its existence
- Code is read far more often than it's written - optimize for readability
- The best code is often the code you don't write
- Elegance emerges from clarity of intent and economy of expression
**Your Review Process:**
1. **Initial Assessment**: Quickly identify the code's purpose and overall structure. Look for the forest before examining the trees.
2. **Simplification Analysis**:
- Identify redundant code, unnecessary abstractions, or over-engineering
- Look for opportunities to reduce cyclomatic complexity
- Suggest removing code that doesn't add clear value
- Recommend combining similar functions or extracting common patterns
- Challenge every level of indirection - is it truly needed?
3. **Best Practices Review**:
- Ensure SOLID principles are followed where appropriate
- Check for proper error handling without over-complication
- Verify naming conventions are clear and self-documenting
- Assess whether the code follows the principle of least surprise
- Look for potential performance issues that stem from poor design
4. **Elegance Improvements**:
- Suggest more idiomatic approaches for the language being used
- Recommend functional approaches where they increase clarity
- Identify where declarative code would be cleaner than imperative
- Look for opportunities to leverage built-in language features
- Suggest ways to make the code more composable and reusable
**Your Feedback Style:**
- Be direct but constructive - explain why something should change
- Provide concrete examples of improvements, not just criticism
- Prioritize your suggestions: critical issues first, then nice-to-haves
- When suggesting changes, show the before and after code
- Acknowledge good patterns when you see them
**Output Format:**
Structure your review as follows:
1. **Summary**: Brief overview of the code's quality and main concerns (2-3 sentences)
2. **Critical Issues** (if any): Problems that must be addressed
- Issue description
- Current code snippet
- Suggested improvement with explanation
3. **Simplification Opportunities**: Ways to make the code more minimal
- What can be removed or combined
- Specific refactoring suggestions with examples
4. **Elegance Enhancements**: Improvements for cleaner, more idiomatic code
- Pattern improvements
- Better use of language features
5. **Positive Observations**: What's already well done (be specific)
**Special Considerations:**
- If you notice the code follows project-specific patterns from CLAUDE.md or other context, respect those patterns while still suggesting improvements within those constraints
- Focus on recently written or modified code unless explicitly asked to review entire files
- If the code is already quite good, say so - don't invent problems
- Consider the context and purpose - a quick script has different standards than production code
- Balance pragmatism with idealism - suggest the ideal but acknowledge practical constraints
Remember: Your goal is to help create code that other developers will thank the author for writing. Code that is a joy to maintain, extend, and understand. Every suggestion should move toward that goal.

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

@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Always use functions from logger.ts for logging

View file

@ -0,0 +1,4 @@
---
alwaysApply: true
---
Use "npx" command to run test

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

View file

@ -7,7 +7,7 @@ on:
push:
branches: ["master", "main"]
pull_request:
branches: ["master", "main"]
# Run on all pull requests regardless of target branch
jobs:
build:

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

4
.gitignore vendored
View file

@ -27,3 +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

391
AGENTS.md Normal file
View file

@ -0,0 +1,391 @@
# AGENTS.md
This file provides guidance to any coding agent 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)
- `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
- `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"`
### 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
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`](./designdocs/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"`
### 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
- 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
### 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
**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`](./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
- **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
- Check @tailwind.config.js to understand what tailwind css classnames are available

163
CLAUDE.md
View file

@ -1,164 +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
- `npm run dev` - Start development server with hot reload (runs Tailwind CSS + esbuild in watch mode)
- `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. **State Management**
- Jotai for atomic state management
- React contexts for feature-specific state
- Shared state utilities in `src/sharedState.ts`
6. **Plugin Integration**
- Main entry: `src/main.ts` extends Obsidian Plugin
- Command registration system
- Event handling for Obsidian lifecycle
- Settings persistence and migration
### Key Patterns
- **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
## Code Style Guidelines
### 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
- JSDoc for complex functions
- Organize imports: React → external → internal
## 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
### Manual Test Checklists
**Important**: After each significant change, generate a manual test checklist document that includes:
1. **Overview**: Brief description of what changed
2. **Test Scenarios**: Specific test cases with steps and expected results
3. **Verification Checklist**: List of items to verify functionality
4. **Files Modified**: List of changed files for reference
Example format:
```markdown
# [Feature] Test Instructions
## Overview
Brief description of the feature/fix
## Test Scenarios
### 1. Test Case Name
1. Step one
2. Step two
3. **Expected Result:**
- Expected behavior
- UI state changes
- Data persistence
### 2. Another Test Case
[...]
## Verification Checklist
- [ ] Core functionality works
- [ ] Edge cases handled
- [ ] No regressions
- [ ] Performance acceptable
```
This helps ensure thorough testing and provides documentation for QA.
## 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
@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:

290
README.md
View file

@ -22,185 +22,233 @@ The Ultimate AI Assistant for Your Second Brain
</a>
</p>
Copilot for Obsidian is your best invault AI assistant, designed to listen, act at the speed of thought, and keep you creating in flow—all within Obsidians integrated, tabfree workspace.
## The What
- **🔒 Your data is 100% yours**: Local storage, no ads, and full control of your API keys.
- **🧠 Elevate your second brain**: Tap any OpenAI-compatible or local model to uncover insights, spark connections, and create powerful content.
- **🌐 Instant multimedia understanding**: Drop in webpages, YouTube videos, images, PDFs, or real-time web search for quick insights and summaries.
- **✍️ Create at the speed of thought**: Launch Prompt Palette or edit with AI in one click—your ideas, amplified effortlessly.
_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.
- **🖼️ Multimedia understanding**: Drop in webpages, YouTube videos, images, PDFs, EPUBS, or real-time web search for quick insights.
- **🔍 Smart Vault Search**: Search your vault with chat, no setup required. Embeddings are optional. Copilot delivers results right away.
- **✍️ Composer and Quick Commands**: Interact with your writing with chat, apply changes with 1 click.
- **🗂️ Project Mode**: Create AI-ready context based on folders and tags. Think NotebookLM but inside your vault!
- **🤖 Agent Mode (Plus)**: Unlock an autonomous agent with built-in tool calling. No commands needed. Copilot automatically triggers vault, web searches or any other relevant tool when relevant.
<p align="center">
<em>Your AI assistant in Obsidian—powerful yet intuitive, keeping you in the creative flow.</em>
<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.png" alt="Product UI screenshot" width="800"/>
<img src="./images/product-ui-screenshot.png" alt="Product UI screenshot" width="800"/>
</p>
## Table of Contents
- [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)
## Copilot v4: Agent Mode, Reimagined 🚀
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 ❤️
- *"Copilot is the missing link that turns Obsidian into a true second brain. I use it to draft investment memos with text, code, and visuals—all in one place. Its the first tool that truly unifies how I search, process, organize, and retrieve knowledge without ever leaving Obsidian. With AI-powered search, organization, and reasoning built into my notes, it unlocks insights Id otherwise miss. My workflow is faster, deeper, and more connected than ever—I cant imagine working without it."* - @jasonzhangb, Investor & Research Analyst
- *"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.
@ -219,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>
@ -265,28 +313,6 @@ Please refer to your model providers documentation for the context window siz
</details>
# **💎 Choose the Copilot Plan Thats Right for You**
| **Feature** | **Free Plan ✅** | **Plus Plan 💎** | **Believer Plan 🛡️** |
| ------------------------------------------------------------------ | ---------------- | ---------------- | -------------------- |
| No credit card or sign-up required | ✅ | ❌ | ❌ |
| All open-source features | ✅ | ✅ | ✅ |
| Bring your own API key | ✅ | ✅ | ✅ |
| Best-in-class AI chat in Obsidian | ✅ | ✅ | ✅ |
| Local data store for Vault QA | ✅ | ✅ | ✅ |
| Support | ✅ Essential | ✅ Pro | ✅ Elite |
| AI agent capabilities | ❌ | ✅ | ✅ |
| Image and PDF support | ❌ | ✅ | ✅ |
| Enhanced chat UI (context menu) | ❌ | ✅ | ✅ |
| State-of-the-art embedding models included | ❌ | ✅ | ✅ |
| Exclusive @AI tools (e.g., web, YouTube) | ❌ | ✅ | ✅ |
| Exclusive chat model included in plan | ❌ | ✅ | ✅ |
| Access to exclusive Discord channel | ❌ | ✅ | ✅ |
| Lifetime access | ❌ | ❌ | ✅ |
| Priority access to new features | ❌ | ❌ | ✅ |
| Prioritized feature requests | ❌ | ❌ | ✅ |
| Exclusive access to next-gen chat & embedding models (coming soon) | ❌ | ❌ | ✅ |
## **🙏 Thank You**
If you share the vision of building the most powerful AI agent for our second brain, consider [sponsoring this project](https://github.com/sponsors/logancyang) or buying me a coffee. Help spread the word by sharing Copilot for Obsidian on Twitter/X, Reddit, or your favorite platform!
@ -304,9 +330,13 @@ 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.

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,6 +47,78 @@ module.exports = {
isDesktop: true,
},
parseYaml: jest.fn().mockImplementation((content) => {
return yaml.load(content);
return parseYamlString(content);
}),
Modal: class Modal {
constructor() {
this.open = jest.fn();
this.close = jest.fn();
this.onOpen = jest.fn();
this.onClose = jest.fn();
}
},
App: jest.fn().mockImplementation(() => ({
workspace: {
getActiveFile: jest.fn(),
},
vault: {
read: jest.fn(),
},
})),
ItemView: jest.fn().mockImplementation(function () {
this.containerEl = window.document.createElement("div");
this.onOpen = jest.fn();
this.onClose = jest.fn();
this.getDisplayText = jest.fn().mockReturnValue("Mock View");
this.getViewType = jest.fn().mockReturnValue("mock-view");
this.getIcon = jest.fn().mockReturnValue("document");
}),
Notice: jest.fn().mockImplementation(function (message) {
this.message = message;
this.noticeEl = window.document.createElement("div");
this.hide = jest.fn();
}),
TFile: jest.fn().mockImplementation(function (path) {
this.path = path;
this.name = path.split("/").pop();
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();
this.detach = jest.fn();
this.getViewState = jest.fn().mockReturnValue({});
}),
};
// Mock the global app object
window.app = {
vault: {
getAbstractFileByPath: jest.fn().mockReturnValue({
name: "test-file.md",
path: "test-file.md",
}),
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),
getLeaf: jest.fn().mockReturnValue({
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,48 @@
# Inline Citation System
This guide explains how inline citations are produced across Copilot Plus, Vault QA, and web search, and how the feature is exercised by automated tests.
## Feature Toggle & Surface Area
- `enableInlineCitations` (default `true`) lives in `src/settings/model.ts` and is exposed in the QA settings UI (`src/settings/v2/components/QASettings.tsx`).
- The toggle gates prompt instructions, fallback post-processing, and chat rendering. When disabled the system falls back to a collapsible sources list without inline markers.
## Pipeline Overview
1. **Retrieval Conditioning**
- Both `CopilotPlusChainRunner.prepareLocalSearchResult` and `VaultQAChainRunner` sanitize note content with `sanitizeContentForCitations` to strip stray `[^n]`/`[n]` markers before prompting.
- Retrieved notes receive stable `__sourceId` values and are serialized with `formatSearchResultsForLLM`; `deduplicateSources` keeps the highest-scoring entry per path/title.
- A compact source catalog is built via `formatSourceCatalog`, and Copilot Plus caches the first 20 entries in `lastCitationSources` for fallback footnotes.
2. **Prompt Assembly**
- `CITATION_RULES` and `WEB_CITATION_RULES` live in `src/LLMProviders/chainRunner/utils/citationUtils.ts`.
- `getCitationInstructions` (Copilot Plus) and `getQACitationInstructionsConditional` (Vault QA) append guidance and a source catalog only when inline citations are enabled.
- Web search calls `getWebSearchCitationInstructions` so external sources emit `[title](url)` definitions while vault answers stay on `[[Note]]` links.
3. **Response Safeguards**
- `addFallbackSources` appends a `#### Sources` block when the model produced inline markers but no definitions. Detection relies on `hasExistingCitations`, which now accepts alternate headings (e.g., `## Sources`, `Sources -`) and `<summary>Sources</summary>` blocks.
- Copilot Plus passes structured `lastCitationSources` into the fallback helper; Vault QA derives titles from the retriever output.
4. **Chat Rendering**
- `src/components/chat-components/ChatSingleMessage.tsx` always pipes assistant messages through `processInlineCitations`.
- The helper extracts the trailing sources section, builds a first-mention map with `buildCitationMap`, normalizes references (`normalizeCitations`) so constructs like `[^7][^8]` become `[1][2]`, and converts definitions (`convertFootnoteDefinitions`) into clickable wiki links or Markdown anchors.
- Duplicate definitions collapse via `consolidateDuplicateSources` + `updateCitationsForConsolidation`, keeping numbering stable. When the sources block is not footnote formatted or citations are disabled, the renderer falls back to a simple `<details>` list.
## Testing
- `src/LLMProviders/chainRunner/utils/citationUtils.test.ts`
- Sanitization, catalog formatting, and fallback insertion.
- `hasExistingCitations` coverage for markdown headings, plain `Sources` labels, and `<summary>` wrappers.
- Regression suites for non-sequential citations, duplicate source consolidation, and consecutive markers (`[^7][^8]`).
- `src/LLMProviders/chainRunner/utils/searchResultUtils.test.ts`
- Ensures retrieved documents are serialized with stable IDs and filtered for `includeInContext` before prompting.
- `src/tools/ToolResultFormatter.test.ts`
- Verifies the local search tool emits JSON with the `{ type: "local_search", documents: [...] }` shape expected by the chain runners.
## Manual QA Checklist
- Vault QA turn using only local search: confirm inline `[1]` markers and numbered sources render without duplication.
- Mixed Copilot Plus turn (local search + another tool): ensure fallback still works if the model omits the sources block.
- Web search answer: verify footnote definitions render as `[title](url)` links when citations are enabled.
## Watchlist
- `sanitizeContentForCitations` intentionally strips bracketed numbers; keep an eye on domains (math, law) where literal `[1990]` values might be desirable.
- Inline citations remain model-dependent. `addFallbackSources` guarantees a sources list, but the UI still reflects whatever inline markers the provider returns.

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

@ -0,0 +1,755 @@
# Message Architecture & Context Design
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
- Each message is stored exactly once in `MessageRepository`
- All UI and LLM views are computed from this single storage
- No complex dual-array synchronization or ID matching
### Clean Architecture Flow
```
User Input → ChatUIState → ChatManager → getCurrentMessageRepo() → MessageRepository + ContextManager
↓ ↓
ChatPersistenceManager Project-specific storage
UI Components ← ChatUIState ← Computed Views ← MessageRepository
LLM Processing ← Chain Memory ← getLLMMessages() ← MessageRepository
```
### Context Always Fresh
- Context is reprocessed when messages are edited
- No stale context issues from cached processing
- Ensures accurate context for LLM interactions
### Project Isolation
- Each project maintains its own isolated chat history
- Automatic detection and switching when project changes
- Zero configuration required - works automatically
- Non-project chats use a default repository
## Core Components
### 1. MessageRepository (`src/core/MessageRepository.ts`)
**Purpose**: Single source of truth for all messages
**Key Concepts**:
- Stores `StoredMessage` objects with both `displayText` and `processedText`
- `displayText`: What the user typed or AI responded (for UI display)
- `processedText`: For user messages, includes context. For AI messages, same as display
**Core Methods**:
```typescript
// Add new message
addMessage(
displayText: string,
processedText: string,
sender: string,
context?: MessageContext,
content?: MessageContent[]
): string
// Get computed views
getDisplayMessages(): ChatMessage[] // For UI rendering
getLLMMessages(): ChatMessage[] // For AI processing
// Edit operations
editMessage(id: string, newDisplayText: 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
**Responsibilities**:
- Orchestrates MessageRepository, ContextManager, and LLM operations
- Handles all message CRUD operations with proper error handling
- Synchronizes with chain memory for conversation history
- Manages context processing lifecycle
- **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
// Send new message with context processing
async sendMessage(displayText: string, context: MessageContext, chainType: ChainType, includeActiveNote?: boolean): Promise<string>
// Edit message and reprocess context
async editMessage(messageId: string, newText: string, chainType: ChainType, includeActiveNote?: boolean): Promise<boolean>
// Regenerate AI response
async regenerateMessage(messageId: string, onUpdateMessage: Function, onAddMessage: Function): Promise<boolean>
// Memory synchronization
private async updateChainMemory(): Promise<void>
// Project management
private getCurrentMessageRepo(): MessageRepository // Auto-detects current project
async handleProjectSwitch(): Promise<void> // Forces project detection
// Persistence
async saveChat(modelKey: string): Promise<{ success: boolean; path?: string; error?: string }>
```
**Project Isolation Implementation**:
```typescript
// Internal structure
private projectMessageRepos: Map<string, MessageRepository>
// Automatic project detection
getCurrentMessageRepo() {
const currentProjectId = ProjectManager.getCurrentProjectId() || defaultProjectKey;
if (!this.projectMessageRepos.has(currentProjectId)) {
// Create new repository for this project
const repo = new MessageRepository();
this.projectMessageRepos.set(currentProjectId, repo);
}
return this.projectMessageRepos.get(currentProjectId)!;
}
```
### 3. ChatUIState (`src/state/ChatUIState.ts`)
**Purpose**: Clean UI-only state manager
**Design Philosophy**:
- Delegates ALL business logic to ChatManager
- Provides React integration with subscription mechanism
- Replaces legacy SharedState with minimal, focused approach
**React Integration**:
```typescript
// Subscribe to state changes
subscribe(listener: () => void): () => void
// Delegate operations to ChatManager
async sendMessage(displayText: string, context: MessageContext, chainType: ChainType, includeActiveNote?: boolean): Promise<string>
getMessages(): ChatMessage[] // Computed view for UI
// Project and persistence operations
async handleProjectSwitch(): Promise<void> // Handle UI updates for project switch
async saveChat(modelKey: string): Promise<{ success: boolean; path?: string; error?: string }>
// Legacy compatibility (for backward compatibility)
get chatHistory(): ChatMessage[]
addMessage(message: ChatMessage): void
clearChatHistory(): void
// Notify React components of changes
private notifyListeners(): void
```
### 4. ContextManager (`src/core/ContextManager.ts`)
**Purpose**: Handles context processing and reprocessing with layered context architecture
**Key Features**:
- **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`)
**Purpose**: Handles saving and loading chat history to/from markdown files
**Key Features**:
- Project-aware file naming (prefixes with project ID)
- Filters chat history files based on current project
- Parses and formats chat content for storage
- Integrated with ChatManager for seamless persistence
**Core Methods**:
```typescript
// Save chat to markdown file
async saveChat(messages: ChatMessage[], modelKey: string, projectId?: string): Promise<{ success: boolean; path?: string; error?: string }>
// Get available chat history files
async getChatHistoryFiles(): Promise<TFile[]>
// File naming convention
// Project chats: `[projectId]-[timestamp]-[modelKey]-chat.md`
// Non-project chats: `[timestamp]-[modelKey]-chat.md`
```
## Architecture Diagrams
### Complete System Architecture
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ User Interface Layer │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Chat.tsx │ ◄────── uses ──────────► │ CopilotView.tsx │ │
│ │ │ │ │ │
│ └────────┬────────┘ └──────────────────┘ │
│ │ │
│ │ subscribes to & calls │
│ ▼ │
└───────────┬─────────────────────────────────────────────────────────────────────────┘
┌───────────┴─────────────────────────────────────────────────────────────────────────┐
│ State Layer │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │ │
│ ┌────────▼────────┐ │
│ │ ChatUIState │ - React state management │
│ │ │ - Subscription mechanism for UI updates │
│ │ │ - Delegates all business logic to ChatManager │
│ └────────┬────────┘ │
│ │ │
└───────────┴─────────────────────────────────────────────────────────────────────────┘
│ delegates to
┌───────────▼─────────────────────────────────────────────────────────────────────────┐
│ Business Logic Layer │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ orchestrates ┌─────────────────────────────┐ │
│ │ ChatManager │ ◄──────────────────────────► │ ContextManager (singleton) │ │
│ │ │ │ │ │
│ │ - Message CRUD │ │ - Process message context │ │
│ │ - Project │ │ - Handle note attachments │ │
│ │ isolation │ │ - Reprocess on edit │ │
│ │ - Memory sync │ └─────────────────────────────┘ │
│ └────────┬────────┘ │
│ │ │
│ │ manages ┌─────────────────────────────┐ │
│ │ │ ChatPersistenceManager │ │
│ ├──────────────────────────────────────►│ │ │
│ │ │ - Save/load chat history │ │
│ │ │ - Project-aware file naming │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ coordinates ┌─────────────────────────────┐ │
│ ├──────────────────────────────────────►│ ChainManager │ │
│ │ │ │ │
│ │ │ - Memory management │ │
│ │ │ - LLM chain operations │ │
│ │ └──────────┬──────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────────────────┐ │
│ │ │ MemoryManager │ │
│ │ │ │ │
│ │ │ - Chain memory storage │ │
│ │ │ - Conversation history │ │
│ │ └─────────────────────────────┘ │
└───────────┴─────────────────────────────────────────────────────────────────────────┘
┌───────────▼─────────────────────────────────────────────────────────────────────────┐
│ Data Storage Layer │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ MessageRepository │ │
│ │ │ │
│ │ ┌─────────────────┐ Computed Views ┌────────────────────────────┐ │ │
│ │ │ StoredMessage[] │ ──────────────────────► │ getDisplayMessages() │ │ │
│ │ │ │ │ (for UI rendering) │ │ │
│ │ │ - id │ └────────────────────────────┘ │ │
│ │ │ - displayText │ │ │
│ │ │ - processedText │ ──────────────────────► ┌────────────────────────────┐ │ │
│ │ │ - sender │ │ getLLMMessages() │ │ │
│ │ │ - timestamp │ │ (for AI processing) │ │ │
│ │ │ - context │ └────────────────────────────┘ │ │
│ │ └─────────────────┘ │ │
│ │ │ │
│ │ Single source of truth - no dual storage! │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
## Project Isolation Architecture
### Multi-Repository Design
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ ChatManager │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ projectMessageRepos: Map<string, MessageRepository>
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ "defaultProject" │ │ "project-1" │ │ "project-2" │ │
│ │ │ │ │ │ │ │
│ │ MessageRepo │ │ MessageRepo │ │ MessageRepo │ │
│ │ - Non-project │ │ - Project 1 │ │ - Project 2 │ │
│ │ messages │ │ messages only │ │ messages only │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ └─────────────────────────┴─────────────────────────┘ │
│ │ │
│ getCurrentMessageRepo() │
│ (auto-detects active project) │
│ │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
### Project Switch Flow
```
Project Switch Detected (via Obsidian workspace)
ProjectManager.getCurrentProjectId() returns new ID
ChatManager.getCurrentMessageRepo()
Check if repository exists for project
↓ (if not)
Create new MessageRepository
Store in projectMessageRepos Map
Return project-specific repository
```
## Message Lifecycle
### Example: User Message with Context Note
When a user types "Summarize this note" and attaches "meeting-notes.md":
1. **Input**: User text + attached file → Chat component
2. **Storage**: MessageRepository stores displayText: "Summarize this note"
3. **Processing**: ContextManager reads the note and creates processedText with proper XML structure
4. **Memory Sync**: Chain memory receives the processed version for LLM
5. **UI Update**: Shows message with context badge, displays only "Summarize this note"
6. **LLM Processing**: AI receives full context and generates response
### Context XML Format
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
<note_context>
<title>meeting-notes</title>
<path>docs/meeting-notes.md</path>
<ctime>2024-01-15T10:00:00.000Z</ctime>
<mtime>2024-01-15T14:30:00.000Z</mtime>
<content>
[actual note content here]
</content>
</note_context>
```
#### URL Context
```xml
<url_content>
<url>https://example.com/article</url>
<content>
[fetched content from URL]
</content>
</url_content>
```
#### Selected Text Context
```xml
<selected_text>
<title>Source Note Title</title>
<path>path/to/source.md</path>
<start_line>45</start_line>
<end_line>52</end_line>
<content>
[selected text content]
</content>
</selected_text>
```
#### Error Cases
```xml
<note_context_error>
<title>filename</title>
<path>path/to/file.ext</path>
<error>[Error: Could not process file]</error>
</note_context_error>
```
This separation ensures:
- Clean UI (shows what user typed)
- Rich context for AI (includes note content)
- Reprocessable context on message edits
For detailed examples, see:
- `src/core/MessageLifecycle.test.ts` - Complete lifecycle demonstration with context notes
- `src/core/MessageLifecycle.xmltags.test.ts` - XML tag formatting tests and examples
### 1. Sending a New Message
```
User Input
↓ (via Chat component)
ChatUIState.sendMessage()
ChatManager.sendMessage()
MessageRepository.addMessage() // Store with basic content
ContextManager.processMessageContext() // Add context
MessageRepository.updateProcessedText() // Update with processed text + context envelope
ChatManager.updateChainMemory() // Sync to LLM
ChatUIState.notifyListeners() // Update UI
```
### 2. Editing a Message
```
User Edit
ChatUIState.editMessage()
ChatManager.editMessage()
MessageRepository.editMessage() // Update display text
ContextManager.reprocessMessageContext() // Fresh context
ChatManager.updateChainMemory() // Sync to LLM
ChatUIState.notifyListeners() // Update UI
```
### 3. Message Display
```
React Component Render
ChatUIState.getMessages()
ChatManager.getDisplayMessages()
ChatManager.getCurrentMessageRepo() // Project-aware
MessageRepository.getDisplayMessages() // Computed view
Filter visible messages → Map to ChatMessage format
```
### 4. Saving Chat History
```
User Save Action
Chat.tsx → ChatUIState.saveChat(modelKey)
ChatManager.saveChat(modelKey)
Get current project ID and messages
ChatPersistenceManager.saveChat(messages, modelKey, projectId)
Create markdown file with project prefix
Return success with file path
```
### 5. Project Switch
```
Project Change in Obsidian
ChatUIState.handleProjectSwitch()
ChatManager.handleProjectSwitch()
Force getCurrentMessageRepo() to re-detect project
Switch to different MessageRepository
Update chain memory with new project's messages
Notify UI listeners for refresh
```
## Data Structures
### StoredMessage (Internal)
```typescript
interface StoredMessage {
id: string;
displayText: string; // What user typed/AI responded
processedText: string; // With context for user, same as display for AI
sender: string;
timestamp: FormattedDateTime;
context?: MessageContext;
isVisible: boolean;
isErrorMessage?: boolean;
sources?: { title: string; score: number }[];
content?: any[];
}
```
### ChatMessage (External Interface)
```typescript
interface ChatMessage {
id?: string;
message: string; // Display text
originalMessage?: string; // Processed text
sender: string;
timestamp: FormattedDateTime | null;
isVisible: boolean;
context?: MessageContext;
isErrorMessage?: boolean;
sources?: { title: string; score: number }[];
content?: any[];
}
```
### MessageContext
```typescript
interface MessageContext {
notes: TFile[];
urls: string[];
selectedTextContexts: SelectedTextContext[];
}
```
## Chat History Loading
### Pending Message Mechanism
The new architecture uses a "pending message" pattern for loading chat history:
```
main.ts.loadChatHistory()
Parse messages from file
CopilotView.setPendingMessages()
Chat component receives pendingMessages prop
useEffect detects pendingMessages
ChatUIState.loadMessages()
onPendingMessagesProcessed() callback clears pending
```
### Project-Aware Loading
When loading chat history:
1. ChatPersistenceManager filters files based on current project
2. Only shows chat files prefixed with current project ID
3. Non-project chats visible when no project is active
## Testing Strategy
### Unit Tests
- **MessageRepository**: 23 comprehensive tests including bug prevention
- **ChatManager**: 25+ tests covering all critical functionality
- **Component Tests**: MessageContext duplicate key prevention
### Bug Prevention Tests
1. **Context Badge Bug**: Ensures context displays correctly
2. **Memory Synchronization**: Prevents chat memory count mismatches
3. **Edit Message Bug**: Verifies proper context reprocessing
4. **Duplicate Notes**: Prevents React key conflicts in context display
## Migration from SharedState
### Before (Legacy)
```typescript
// Multiple sources of truth
const sharedState = {
currentChatMessages: ChatMessage[],
chatHistory: ChatMessage[],
// Complex sync logic between arrays
}
```
### After (Clean Architecture)
```typescript
// Single source of truth
const messageRepository = new MessageRepository();
const chatManager = new ChatManager(messageRepository, ...);
const chatUIState = new ChatUIState(chatManager);
// Computed views
const displayMessages = chatUIState.getMessages(); // For UI
const llmMessages = chatManager.getLLMMessages(); // For AI
```
## Performance Considerations
### Memory Efficiency
- Single storage eliminates duplicate message objects
- Computed views are generated on-demand
- Context processing only when needed
### React Optimization
- Subscription-based updates minimize re-renders
- Unique keys prevent React reconciliation issues
- State changes are batched through ChatUIState
## Key Architectural Features
### Project Isolation Benefits
1. **Complete Separation**: Each project has entirely separate chat history
2. **Automatic Management**: No user configuration needed
3. **Seamless Switching**: Instant context switch when changing projects
4. **Memory Efficient**: Only active project's messages in memory
5. **Fresh Start**: Each project starts with empty chat history
### Persistence Integration
1. **Project-Aware Naming**: Files prefixed with project ID
2. **Filtered File Lists**: Only shows relevant chat files
3. **Consistent Format**: Same markdown format across all projects
4. **Error Handling**: Graceful fallbacks for save/load failures
## Troubleshooting
### Common Issues
1. **Context not updating**: Check if `updateChainMemory()` is called after edits
2. **UI not refreshing**: Ensure `notifyListeners()` is called after state changes
3. **Memory count mismatch**: Verify `truncateAfterMessageId()` updates chain memory
4. **Duplicate context badges**: Check React keys in MessageContext component
5. **Wrong project messages**: Check `getCurrentProjectId()` returns expected value
6. **Missing chat history**: Verify project ID in filename matches current project
### Debug Methods
```typescript
// Check message repository state
messageRepo.getDebugInfo();
// Check chat manager state
chatManager.getDebugInfo();
// Check LLM vs display message counts
console.log({
display: chatUIState.getMessages().length,
llm: chatManager.getLLMMessages().length,
});
// Check current project and repository
const debugInfo = chatManager.getDebugInfo();
console.log({
currentProject: debugInfo.currentProjectId,
totalProjects: debugInfo.projectCount,
messagesByProject: debugInfo.messageCountByProject,
});
```
## 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
- `src/core/ChatManager.ts` - Business logic with project isolation
- `src/state/ChatUIState.ts` - UI state management
- `src/core/ContextManager.ts` - Context processing
- `src/core/ChatPersistenceManager.ts` - Chat history persistence
### React Integration
- `src/components/Chat.tsx` - Main chat component
- `src/hooks/useChatManager.ts` - React hook for ChatUIState
- `src/components/chat-components/ChatSingleMessage.tsx` - Message display
### Testing
- `src/core/MessageRepository.test.ts` - Repository tests
- `src/core/ChatManager.test.ts` - Manager tests
- `src/core/MessageLifecycle.test.ts` - Complete lifecycle examples with context notes
- `src/core/MessageLifecycle.xmltags.test.ts` - XML tag formatting tests and examples
- `src/components/chat-components/MessageContext.test.tsx` - Context display tests

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` |

363
designdocs/TOOLS.md Normal file
View file

@ -0,0 +1,363 @@
# Tool System Documentation
## Overview
The Copilot tool system uses a centralized registry pattern that makes it easy to add new tools, including future MCP (Model Context Protocol) tools. All tools are managed through a singleton `ToolRegistry` that provides a unified interface for tool discovery, configuration, and execution.
## Tool Prompt Architecture
### How Tool Instructions Flow to the LLM
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** (Zod schemas in tool implementations)
- Defines parameter formats, rules, and validation
- Provided to LLM via `bindTools()` for native tool calling
2. **Custom Prompt Instructions** (in `builtinTools.ts`)
- 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 via Zod, while custom instructions provide behavioral guidance.
1. **Clear Separation**
- **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**
- External tools provide immutable schemas
- We add custom instructions without modifying their code
3. **Example**
```typescript
// 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 (behavioral guidance)
customPromptInstructions: `
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 via Zod
- Schemas are automatically provided to LLM via `bindTools()`
- Focus on the data contract
2. **Custom Instructions: Behavioral Guidance**
- 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
- `src/tools/ToolRegistry.ts` - Central registry for all tools
- `src/tools/builtinTools.ts` - Built-in tool definitions and initialization
- `src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts` - Tool execution in the agent
- `src/settings/v2/components/ToolSettingsSection.tsx` - Settings UI for tool configuration
### Tool Registry Pattern
The `ToolRegistry` is a singleton that manages all tools:
```typescript
class ToolRegistry {
static getInstance(): ToolRegistry;
register(definition: ToolDefinition): void;
registerAll(definitions: ToolDefinition[]): void;
getAllTools(): ToolDefinition[];
getEnabledTools(enabledToolIds: Set<string>, vaultAvailable: boolean): SimpleTool<any, any>[];
getToolsByCategory(): Map<string, ToolDefinition[]>;
getConfigurableTools(): ToolDefinition[];
getToolMetadata(id: string): ToolMetadata | undefined;
clear(): void;
}
```
### Tool Definition Structure
```typescript
interface ToolDefinition {
tool: SimpleTool<any, any>; // The actual tool implementation
metadata: ToolMetadata; // UI and configuration metadata
}
interface ToolMetadata {
id: string; // Unique identifier
displayName: string; // Shown in UI
description: string; // Help text
category: "search" | "time" | "file" | "media" | "mcp" | "custom";
isAlwaysEnabled?: boolean; // If true, not configurable (e.g., time tools)
requiresVault?: boolean; // Needs vault access
customPromptInstructions?: string; // Tool-specific prompts
}
```
## Adding a New Built-in Tool
### 1. Implement the Tool
Create your tool following the `SimpleTool` interface:
```typescript
// Example: New built-in tool
import { z } from "zod";
import { SimpleTool } from "./SimpleTool";
export const myNewTool: SimpleTool<{ input: string }, { result: string }> = {
name: "myNewTool",
description: "Description for the LLM to understand when to use this tool",
schema: z.object({
input: z.string().describe("The input parameter description"),
}),
func: async (params) => {
// Tool implementation
const result = await performOperation(params.input);
return { result };
},
};
```
### 2. Add to Built-in Tools
Update `src/tools/builtinTools.ts`:
```typescript
export const BUILTIN_TOOLS: ToolDefinition[] = [
// ... existing tools ...
{
tool: myNewTool,
metadata: {
id: "myNewTool",
displayName: "My New Tool",
description: "User-friendly description for settings UI",
category: "custom", // Choose appropriate category
// Optional flags:
isAlwaysEnabled: false, // Set true if tool should always be available
requiresVault: true, // Set true if tool needs vault access
customPromptInstructions: "Special instructions for the AI when using this tool",
},
},
];
```
### 3. Update Default Settings (if configurable)
If the tool is configurable (not always-enabled), add its ID to the default enabled tools in `src/constants.ts`:
```typescript
autonomousAgentEnabledToolIds: [
"localSearch",
"webSearch",
"pomodoro",
"youtubeTranscription",
"writeToFile",
"myNewTool" // Add your tool ID here
],
```
## Adding MCP Tools (Future Implementation)
### 1. MCP Tool Wrapper
Create a wrapper to convert MCP tools to the SimpleTool interface:
```typescript
function createMcpToolWrapper(serverName: string, mcpTool: McpTool): SimpleTool<any, any> {
return {
name: `${serverName}_${mcpTool.name}`,
description: mcpTool.description || `MCP tool from ${serverName}`,
schema: convertMcpSchemaToZod(mcpTool.inputSchema),
func: async (params) => {
// Call the MCP server
const result = await mcpHub.callTool(serverName, mcpTool.name, params);
// Convert MCP response to expected format
return {
result: formatMcpResponse(result),
};
},
};
}
```
### 2. Dynamic MCP Tool Registration
Register MCP tools when servers connect:
```typescript
// In your MCP initialization code
export async function registerMcpServerTools(serverName: string, mcpTools: McpTool[]) {
const registry = ToolRegistry.getInstance();
for (const mcpTool of mcpTools) {
registry.register({
tool: createMcpToolWrapper(serverName, mcpTool),
metadata: {
id: `mcp_${serverName}_${mcpTool.name}`,
displayName: mcpTool.displayName || mcpTool.name,
description: mcpTool.description || `MCP tool from ${serverName}`,
category: "mcp",
// MCP tools are user-configurable by default
isAlwaysEnabled: false,
// Add any MCP-specific prompt instructions
customPromptInstructions: mcpTool.systemPrompt,
},
});
}
}
// When MCP server disconnects
export function unregisterMcpServerTools(serverName: string) {
const registry = ToolRegistry.getInstance();
const allTools = registry.getAllTools();
// Remove tools from this server
const toolsToKeep = allTools.filter((t) => !t.metadata.id.startsWith(`mcp_${serverName}_`));
registry.clear();
registry.registerAll(toolsToKeep);
// Re-initialize built-in tools
initializeBuiltinTools(app.vault);
}
```
### 3. Schema Conversion Helper
Convert MCP JSON Schema to Zod schema:
```typescript
function convertMcpSchemaToZod(jsonSchema: any): z.ZodSchema {
// Basic implementation - extend as needed
if (jsonSchema.type === "object") {
const shape: any = {};
for (const [key, prop] of Object.entries(jsonSchema.properties || {})) {
const propSchema = prop as any;
if (propSchema.type === "string") {
shape[key] = z.string();
if (propSchema.description) {
shape[key] = shape[key].describe(propSchema.description);
}
} else if (propSchema.type === "number") {
shape[key] = z.number();
} else if (propSchema.type === "boolean") {
shape[key] = z.boolean();
} else if (propSchema.type === "array") {
shape[key] = z.array(z.any()); // Simplification
} else if (propSchema.type === "object") {
shape[key] = z.object({});
}
// Handle optional properties
if (!jsonSchema.required?.includes(key)) {
shape[key] = shape[key].optional();
}
}
return z.object(shape);
}
// Fallback for other types
return z.any();
}
```
### 4. Settings Storage for MCP Tools
MCP tool preferences are stored in the same array as built-in tools:
```typescript
// When enabling/disabling MCP tools:
function updateMcpToolSetting(toolId: string, enabled: boolean) {
const settings = getSettings();
const enabledIds = new Set(settings.autonomousAgentEnabledToolIds || []);
if (enabled) {
enabledIds.add(toolId);
} else {
enabledIds.delete(toolId);
}
updateSetting("autonomousAgentEnabledToolIds", Array.from(enabledIds));
}
```
## How the System Works
### Tool Discovery Flow
1. **Initialization**: `initializeBuiltinTools()` registers all built-in tools
2. **MCP Connection**: When MCP servers connect, their tools are dynamically registered
3. **Settings UI**: `ToolSettingsSection` component reads from the registry to generate UI
4. **Tool Execution**: `AutonomousAgentChainRunner.getAvailableTools()` filters tools based on settings
## Tool Call Rendering Roots
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`.
### Manager Responsibilities
- 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.
### Integration Notes
`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`.
### Verification
Run the focused test to cover the streaming behaviour and tool-call integration:
```
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,51 @@
# TODO - Technical Debt & Future Improvements
This document tracks technical debt items and improvements that need to be addressed in the future.
## 1. Docs4LLM SSL Error in Projects Mode
### Issue Description
Document parsing for projects mode is failing with an SSL error (`net::ERR_SSL_BAD_RECORD_MAC_ALERT`) when trying to upload files to the docs4llm API endpoint.
### Technical Details
- **Error Location**: `src/LLMProviders/brevilabsClient.ts:185` in `makeFormDataRequest` method
- **Root Cause**: The method uses native `fetch` API instead of Obsidian's `requestUrl` API
- **Context**: While regular JSON requests were migrated to use `safeFetch` (which uses `requestUrl`) in commit e49aafa to fix CORS issues, the `makeFormDataRequest` method was not updated
### Why Current Approaches Won't Work
1. **Backend Constraint**: The `/docs4llm` endpoint only accepts multipart/form-data format with `files: List[UploadFile]`
2. **Obsidian Limitation**: The existing `safeFetch` function is hardcoded for `application/json` content type
3. **No JSON Alternative**: Unlike `pdf4llm` which accepts base64 JSON, there's no JSON endpoint for `docs4llm`
### Recommended Solution
Create a new `safeFetchFormData` function that:
1. Uses Obsidian's `requestUrl` API with proper multipart/form-data configuration
2. Handles FormData objects correctly
3. Bypasses CORS and SSL restrictions like `safeFetch` does for JSON
### Alternative Solutions
1. **Backend Modification**: Add a new `/docs4llm-base64` endpoint that accepts base64-encoded JSON payloads
2. **Research Obsidian API**: Investigate if newer versions of Obsidian's `requestUrl` support multipart/form-data
3. **SSL Certificate Fix**: Address the underlying SSL certificate issue (temporary workaround)
### Impact
- Users cannot parse non-markdown files (PDFs, Word docs, etc.) in projects mode
- This affects the core functionality of project context loading
- Workaround: Users must ensure their projects only contain markdown files
### References
- Related commit: e49aafa (Brevilabs CORS issue #918)
- Forum discussion: https://forum.obsidian.md/t/holo-how-to-add-a-png-image-or-file-to-formdata-in-obsidian-like-below-this-help/73420
- Backend implementation: `/Users/chaoyang/webapps/brevilabs-api/app/main.py:1039`
---
_Last updated: 2025-07-18_

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: 367 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": "2.9.1",
"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;

14533
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,143 +1,125 @@
{
"name": "obsidian-copilot",
"version": "2.9.1",
"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.5.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-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.1.2",
"@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.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.2.1",
"@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-slider": "^1.3.5",
"@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",
"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",
"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

@ -0,0 +1,71 @@
import { safeFetchNoThrow } from "@/utils";
import { OpenAIEmbeddings } from "@langchain/openai";
export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
private customConfig: Record<string, unknown>;
constructor(config: Record<string, unknown>) {
super(config);
// Store the config for our custom methods
this.customConfig = config;
}
async embedQuery(text: string): Promise<number[]> {
// Make direct API call to avoid OpenAI client's response processing
const embedding = await this.callEmbeddingAPI([text]);
return embedding[0];
}
async embedDocuments(texts: string[]): Promise<number[][]> {
// Make direct API call to avoid OpenAI client's response processing
const embeddings = await this.callEmbeddingAPI(texts);
return embeddings;
}
private async callEmbeddingAPI(texts: string[]): Promise<number[][]> {
const requestBody = {
model: this.customConfig.modelName,
input: texts,
encoding_format: "float",
};
// Get the correct baseURL, apiKey, and fetch function from the configuration
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 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),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Embedding API request failed: ${response.status} ${response.statusText} - ${errorText}`
);
}
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) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error("Invalid API response format: missing or invalid embedding array");
}
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 { Buffer } from "buffer";
import { Notice } from "obsidian";
import { arrayBufferToBase64 } from "@/utils/base64";
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,32 +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(`==== ${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>(
@ -182,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(`==== ${endpoint} FormData 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)) };
}
@ -212,19 +248,45 @@ export class BrevilabsClient {
/**
* Validate the license key and update the isPlusUser setting.
* @param context Optional context object containing the features that the user is using to validate the license key.
* @returns true if the license key is valid, false if the license key is invalid, and undefined if
* unknown error.
*/
async validateLicenseKey(): Promise<{ isValid: boolean | undefined; plan?: string }> {
async validateLicenseKey(
context?: Record<string, unknown>
): Promise<{ isValid: boolean | undefined; plan?: string }> {
// Build the request body with proper structure
const requestBody: Record<string, unknown> = {
license_key: await getDecryptedKey(getSettings().plusLicenseKey),
};
// Safely spread context if provided, ensuring no conflicts with required fields
if (context && typeof context === "object") {
// Filter out any undefined or null values from context
const filteredContext = Object.fromEntries(
Object.entries(context).filter(([_, value]) => value !== undefined && value !== null)
);
// Remove any reserved fields that must not be overridden by context
const reservedKeys = new Set(["license_key", "user_id"]);
for (const key of reservedKeys) {
if (key in filteredContext) {
delete (filteredContext as Record<string, unknown>)[key];
}
}
// Spread the filtered context into the request body
Object.assign(requestBody, filteredContext);
}
const { data, error } = await this.makeRequest<LicenseResponse>(
"/license",
{
license_key: await getDecryptedKey(getSettings().plusLicenseKey),
},
requestBody,
"POST",
true,
true
);
if (error) {
if (error.message === "Invalid license key") {
turnOffPlus();
@ -237,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,
@ -282,7 +329,7 @@ export class BrevilabsClient {
async pdf4llm(binaryContent: ArrayBuffer): Promise<Pdf4llmResponse> {
// Convert ArrayBuffer to base64 string
const base64Content = Buffer.from(binaryContent).toString("base64");
const base64Content = arrayBufferToBase64(binaryContent);
const { data, error } = await this.makeRequest<Pdf4llmResponse>("/pdf4llm", {
pdf: base64Content,
@ -392,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,41 +1,33 @@
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,
ChainRunner,
CopilotPlusChainRunner,
LLMChainRunner,
ProjectChainRunner,
VaultQAChainRunner,
} from "@/LLMProviders/chainRunner";
} from "@/LLMProviders/chainRunner/index";
import { logError, logInfo } from "@/logger";
import { HybridRetriever } from "@/search/hybridRetriever";
import VectorStoreManager from "@/search/vectorStoreManager";
import { getSettings, getSystemPrompt, subscribeToSettingsChange } from "@/settings/model";
import { ChatMessage } from "@/sharedState";
import { findCustomModel, isOSeriesModel, isSupportedChain } from "@/utils";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { getSystemPrompt } from "@/system-prompts/systemPromptBuilder";
import { ChatMessage } from "@/types/message";
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[] {
@ -43,30 +35,27 @@ export default class ChainManager {
}
public app: App;
public vectorStoreManager: VectorStoreManager;
public chatModelManager: ChatModelManager;
public memoryManager: MemoryManager;
public promptManager: PromptManager;
public userMemoryManager: UserMemoryManager;
private pendingModelError: Error | null = null;
// A chat history that stores the messages sent and received
// Only reset when the user explicitly clicks "New Chat"
private chatMessages: ChatMessage[] = [];
constructor(app: App, vectorStoreManager: VectorStoreManager) {
this.chatMessages = [];
constructor(app: App) {
// Instantiate singletons
this.app = app;
this.vectorStoreManager = vectorStoreManager;
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)
);
});
}
@ -74,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);
}
}
@ -119,6 +91,7 @@ export default class ChainManager {
options: SetChainOptions = {},
neededReInitChatMode: boolean = true
): Promise<void> {
let newModelKey: string | undefined;
const chainType = getChainType();
const currentProject = getCurrentProject();
@ -126,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) {
@ -168,131 +140,65 @@ 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);
const retriever = new HybridRetriever({
minSimilarityScore: 0.01,
maxK: getSettings().maxSourceChunks,
salientTerms: [],
});
// 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()}`);
}
}
private getChainRunner(): ChainRunner {
const chainType = getChainType();
const settings = getSettings();
switch (chainType) {
case ChainType.LLM_CHAIN:
return new LLMChainRunner(this);
case ChainType.VAULT_QA_CHAIN:
return new VaultQAChainRunner(this);
case ChainType.COPILOT_PLUS_CHAIN:
// Use AutonomousAgentChainRunner if the setting is enabled
if (settings.enableAutonomousAgent) {
return new AutonomousAgentChainRunner(this);
}
return new CopilotPlusChainRunner(this);
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) {
await this.vectorStoreManager.indexVaultToVectorStore();
}
/**
* 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(
@ -306,12 +212,15 @@ export default class ChainManager {
updateLoading?: (loading: boolean) => void;
} = {}
) {
const { debug = false, ignoreSystemMessage = false } = options;
const { ignoreSystemMessage = false } = options;
if (debug) console.log("==== 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();
@ -331,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,
});*/
@ -346,33 +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 });
}
}
}
public clearHistory() {
this.chatMessages = [];
}
public getChatMessages(): ChatMessage[] {
return this.chatMessages;
}
public setChatMessages(messages: ChatMessage[]) {
this.chatMessages = [...messages];
}
public addChatMessage(message: ChatMessage) {
this.chatMessages.push(message);
}
}

View file

@ -1,983 +0,0 @@
import { getCurrentProject } from "@/aiParams";
import { getStandaloneQuestion } from "@/chainUtils";
import {
ABORT_REASON,
AI_SENDER,
EMPTY_INDEX_ERROR_MESSAGE,
LOADING_MESSAGES,
MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT,
ModelCapability,
} from "@/constants";
import {
ImageBatchProcessor,
ImageContent,
ImageProcessingResult,
MessageContent,
} from "@/imageProcessing/imageProcessor";
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { logError, logInfo, logWarn } from "@/logger";
import { HybridRetriever } from "@/search/hybridRetriever";
import { getSettings, getSystemPrompt } from "@/settings/model";
import { ChatMessage } from "@/sharedState";
import { ToolManager } from "@/tools/toolManager";
import {
err2String,
extractChatHistory,
extractUniqueTitlesFromDocs,
extractYoutubeUrl,
formatDateTime,
getApiErrorMessage,
getMessageRole,
withSuppressedTokenWarnings,
} from "@/utils";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { Notice } from "obsidian";
import ChainManager from "./chainManager";
import { COPILOT_TOOL_NAMES, IntentAnalyzer } from "./intentAnalyzer";
import ProjectManager from "./projectManager";
class ThinkBlockStreamer {
private hasOpenThinkBlock = false;
private fullResponse = "";
constructor(private updateCurrentAiMessage: (message: string) => void) {}
private handleClaude37Chunk(content: any[]) {
let textContent = "";
for (const item of content) {
switch (item.type) {
case "text":
textContent += item.text;
break;
case "thinking":
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
}
this.fullResponse += item.thinking;
this.updateCurrentAiMessage(this.fullResponse);
return true; // Indicate we handled a thinking chunk
}
}
if (textContent) {
this.fullResponse += textContent;
}
return false; // No thinking chunk handled
}
private handleDeepseekChunk(chunk: any) {
// Handle standard string content
if (typeof chunk.content === "string") {
this.fullResponse += chunk.content;
}
// Handle deepseek reasoning/thinking content
if (chunk.additional_kwargs?.reasoning_content) {
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
}
this.fullResponse += chunk.additional_kwargs.reasoning_content;
return true; // Indicate we handled a thinking chunk
}
return false; // No thinking chunk handled
}
processChunk(chunk: any) {
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) {
this.fullResponse += "</think>";
this.hasOpenThinkBlock = false;
}
this.updateCurrentAiMessage(this.fullResponse);
}
close() {
// Make sure to close any open think block at the end
if (this.hasOpenThinkBlock) {
this.fullResponse += "</think>";
this.updateCurrentAiMessage(this.fullResponse);
}
return this.fullResponse;
}
}
export interface ChainRunner {
run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string>;
}
abstract class BaseChainRunner implements ChainRunner {
protected chainManager: ChainManager;
constructor(chainManager: ChainManager) {
this.chainManager = chainManager;
}
abstract run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string>;
protected async handleResponse(
fullAIResponse: string,
userMessage: ChatMessage,
abortController: AbortController,
addMessage: (message: ChatMessage) => void,
updateCurrentAiMessage: (message: string) => void,
sources?: { title: string; score: number }[]
) {
// 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)
) {
await this.chainManager.memoryManager
.getMemory()
.saveContext({ input: userMessage.message }, { output: fullAIResponse });
addMessage({
message: fullAIResponse,
sender: AI_SENDER,
isVisible: true,
timestamp: formatDateTime(new Date()),
sources: sources,
});
// Clear the streaming message since it's now in chat history
updateCurrentAiMessage("");
} else if (abortController.signal.reason === ABORT_REASON.NEW_CHAT) {
// Also clear if it's a new chat
updateCurrentAiMessage("");
}
logInfo(
"==== Chat Memory ====\n",
(this.chainManager.memoryManager.getMemory().chatHistory as any).messages.map(
(m: any) => m.content
)
);
logInfo("==== Final AI Response ====\n", fullAIResponse);
return fullAIResponse;
}
protected async handleError(
error: any,
addMessage?: (message: ChatMessage) => void,
updateCurrentAiMessage?: (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;
let errorMessage = "";
// Check for specific error messages
if (error?.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 =
"You do not have access to this model or the model does not exist, please check with your API provider.";
} else {
errorMessage = `${errorCode}`;
}
logError(errorData);
if (addMessage && updateCurrentAiMessage) {
updateCurrentAiMessage("");
// 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);
}
}
}
class LLMChainRunner extends BaseChainRunner {
async run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string> {
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage);
try {
// Get chat history from memory
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
// Create messages array starting with system message
const messages: any[] = [];
// Add system message if available
const systemPrompt = getSystemPrompt();
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
messages.push({
role: "user",
content: userMessage.message,
});
logInfo("==== Final Request to AI ====\n", messages);
// Stream with abort signal
const chatStream = await withSuppressedTokenWarnings(() =>
this.chainManager.chatModelManager.getChatModel().stream(messages, {
signal: abortController.signal,
})
);
for await (const chunk of chatStream) {
if (abortController.signal.aborted) {
logInfo("Stream iteration aborted", { reason: abortController.signal.reason });
break;
}
streamer.processChunk(chunk);
}
} catch (error: any) {
// Check if the error is due to abort signal
if (error.name === "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);
}
}
// Always return the response, even if partial
const response = streamer.close();
// Only skip saving if it's a new chat (clearing everything)
if (abortController.signal.aborted && abortController.signal.reason === ABORT_REASON.NEW_CHAT) {
updateCurrentAiMessage("");
return "";
}
return this.handleResponse(
response,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
);
}
}
class VaultQAChainRunner extends BaseChainRunner {
async run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string> {
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage);
try {
// Add check for empty index
const indexEmpty = await this.chainManager.vectorStoreManager.isIndexEmpty();
if (indexEmpty) {
return this.handleResponse(
EMPTY_INDEX_ERROR_MESSAGE,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
);
}
// Get chat history from memory
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;
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;
}
// Create retriever (similar to how it's done in chainManager)
const retriever = new HybridRetriever({
minSimilarityScore: 0.01,
maxK: getSettings().maxSourceChunks,
salientTerms: [],
});
// Retrieve relevant documents
const retrievedDocs = await retriever.getRelevantDocuments(standaloneQuestion);
// Store retrieved documents for sources
this.chainManager.storeRetrieverDocuments(retrievedDocs);
// Format documents as context
const context = retrievedDocs.map((doc: any) => doc.pageContent).join("\n\n");
// Create messages array
const messages: any[] = [];
// Add system message with QA instruction
const systemPrompt = getSystemPrompt();
const qaInstructions =
"\n\nAnswer the question with as detailed as possible based only on the following context:\n" +
context;
const fullSystemMessage = systemPrompt + qaInstructions;
const chatModel = this.chainManager.chatModelManager.getChatModel();
if (fullSystemMessage) {
messages.push({
role: getMessageRole(chatModel),
content: fullSystemMessage,
});
}
// Add chat history
for (const entry of chatHistory) {
messages.push({ role: entry.role, content: entry.content });
}
// Add current user question
messages.push({
role: "user",
content: userMessage.message,
});
logInfo("==== Final Request to AI ====\n", messages);
// Stream with abort signal
const chatStream = await withSuppressedTokenWarnings(() =>
this.chainManager.chatModelManager.getChatModel().stream(messages, {
signal: abortController.signal,
})
);
for await (const chunk of chatStream) {
if (abortController.signal.aborted) {
logInfo("VaultQA stream iteration aborted", { reason: abortController.signal.reason });
break;
}
streamer.processChunk(chunk);
}
} catch (error: any) {
// Check if the error is due to abort signal
if (error.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);
}
}
// Always get the response, even if partial
let fullAIResponse = streamer.close();
// Only skip saving if it's a new chat (clearing everything)
if (abortController.signal.aborted && abortController.signal.reason === ABORT_REASON.NEW_CHAT) {
updateCurrentAiMessage("");
return "";
}
// Add sources to the response
fullAIResponse = this.addSourcestoResponse(fullAIResponse);
return this.handleResponse(
fullAIResponse,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
);
}
private addSourcestoResponse(response: string): string {
const docTitles = extractUniqueTitlesFromDocs(this.chainManager.getRetrievedDocuments());
if (docTitles.length > 0) {
const links = docTitles.map((title) => `- [[${title}]]`).join("\n");
response += "\n\n#### Sources:\n\n" + links;
}
return response;
}
}
class CopilotPlusChainRunner extends BaseChainRunner {
private isYoutubeOnlyMessage(message: string): boolean {
const trimmedMessage = message.trim();
const hasYoutubeCommand = trimmedMessage.includes("@youtube");
const youtubeUrl = extractYoutubeUrl(trimmedMessage);
// Check if message only contains @youtube command and a valid URL
const words = trimmedMessage
.split(/\s+/)
.filter((word) => word !== "@youtube" && word.length > 0);
return hasYoutubeCommand && youtubeUrl !== null && words.length === 1;
}
private async processImageUrls(urls: string[]): Promise<ImageProcessingResult> {
const failedImages: string[] = [];
const processedImages = await ImageBatchProcessor.processUrlBatch(
urls,
failedImages,
this.chainManager.app.vault
);
ImageBatchProcessor.showFailedImagesNotice(failedImages);
return processedImages;
}
private async processChatInputImages(content: MessageContent[]): Promise<ImageProcessingResult> {
const failedImages: string[] = [];
const processedImages = await ImageBatchProcessor.processChatImageBatch(
content,
failedImages,
this.chainManager.app.vault
);
ImageBatchProcessor.showFailedImagesNotice(failedImages);
return processedImages;
}
private async extractEmbeddedImages(content: string): Promise<string[]> {
const imageRegex = /!\[\[(.*?\.(png|jpg|jpeg|gif|webp|bmp|svg))\]\]/g;
const matches = [...content.matchAll(imageRegex)];
const images = matches.map((match) => match[1]);
return images;
}
private async buildMessageContent(
textContent: string,
userMessage: ChatMessage
): Promise<MessageContent[]> {
const failureMessages: string[] = [];
const successfulImages: ImageContent[] = [];
const settings = getSettings();
// Collect all image sources
const imageSources: { urls: string[]; type: string }[] = [];
// Safely check and add context URLs
const contextUrls = userMessage.context?.urls;
if (contextUrls && contextUrls.length > 0) {
imageSources.push({ urls: contextUrls, type: "context" });
}
// Process embedded images only if setting is enabled
if (settings.passMarkdownImages) {
const embeddedImages = await this.extractEmbeddedImages(textContent);
if (embeddedImages.length > 0) {
imageSources.push({ urls: embeddedImages, type: "embedded" });
}
}
// Process all image sources
for (const source of imageSources) {
const result = await this.processImageUrls(source.urls);
successfulImages.push(...result.successfulImages);
failureMessages.push(...result.failureDescriptions);
}
// Process existing chat content images if present
const existingContent = userMessage.content;
if (existingContent && existingContent.length > 0) {
const result = await this.processChatInputImages(existingContent);
successfulImages.push(...result.successfulImages);
failureMessages.push(...result.failureDescriptions);
}
// Let the LLM know about the image processing failures
let finalText = textContent;
if (failureMessages.length > 0) {
finalText = `${textContent}\n\nNote: \n${failureMessages.join("\n")}\n`;
}
const messageContent: MessageContent[] = [
{
type: "text",
text: finalText,
},
];
// Add successful images after the text content
if (successfulImages.length > 0) {
messageContent.push(...successfulImages);
}
return messageContent;
}
private hasCapability(model: BaseChatModel, capability: ModelCapability): boolean {
const modelName = (model as any).modelName || (model as any).model || "";
const customModel = this.chainManager.chatModelManager.findModelByName(modelName);
return customModel?.capabilities?.includes(capability) ?? false;
}
private isMultimodalModel(model: BaseChatModel): boolean {
return this.hasCapability(model, ModelCapability.VISION);
}
private async streamMultimodalResponse(
textContent: string,
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void
): Promise<string> {
// Get chat history
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
// Create messages array starting with system message
const messages: any[] = [];
// Add system message if available
let fullSystemMessage = await this.getSystemPrompt();
// Add chat history context to system message if exists
if (chatHistory.length > 0) {
fullSystemMessage +=
"\n\nThe following is the relevant conversation history. Use this context to maintain consistency in your responses:";
}
// Get chat model for role determination for O-series models
const chatModel = this.chainManager.chatModelManager.getChatModel();
// Add the combined system message with appropriate role
if (fullSystemMessage) {
messages.push({
role: getMessageRole(chatModel),
content: `${fullSystemMessage}\nIMPORTANT: Maintain consistency with previous responses in the conversation. If you've provided information about a person or topic before, use that same information in follow-up questions.`,
});
}
// Add chat history
for (const entry of chatHistory) {
messages.push({ role: entry.role, content: entry.content });
}
// Get the current chat model
const chatModelCurrent = this.chainManager.chatModelManager.getChatModel();
const isMultimodalCurrent = this.isMultimodalModel(chatModelCurrent);
// Build message content with text and images for multimodal models, or just text for text-only models
const content = isMultimodalCurrent
? await this.buildMessageContent(textContent, userMessage)
: textContent;
// Add current user message
messages.push({
role: "user",
content,
});
const enhancedUserMessage = content instanceof Array ? (content[0] as any).text : content;
logInfo("Enhanced user message: ", enhancedUserMessage);
logInfo("==== Final Request to AI ====\n", messages);
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage);
// Wrap the stream call with warning suppression
const chatStream = await withSuppressedTokenWarnings(() =>
this.chainManager.chatModelManager.getChatModel().stream(messages, {
signal: abortController.signal,
})
);
for await (const chunk of chatStream) {
if (abortController.signal.aborted) {
logInfo("CopilotPlus multimodal stream iteration aborted", {
reason: abortController.signal.reason,
});
break;
}
streamer.processChunk(chunk);
}
return streamer.close();
}
async run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
updateLoadingMessage?: (message: string) => void;
}
): Promise<string> {
const { updateLoadingMessage } = options;
let fullAIResponse = "";
let sources: { title: string; score: number }[] = [];
let currentPartialResponse = "";
// Wrapper to track partial response
const trackAndUpdateAiMessage = (message: string) => {
currentPartialResponse = message;
updateCurrentAiMessage(message);
};
try {
// Check if this is a YouTube-only message
if (this.isYoutubeOnlyMessage(userMessage.message)) {
const url = extractYoutubeUrl(userMessage.message);
const failMessage =
"Transcript not available. Only videos with the auto transcript option turned on are supported at the moment.";
if (url) {
try {
const response = await BrevilabsClient.getInstance().youtube4llm(url);
if (response.response.transcript) {
return this.handleResponse(
response.response.transcript,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
);
}
return this.handleResponse(
failMessage,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
);
} catch (error) {
logError("Error processing YouTube video:", error);
return this.handleResponse(
failMessage,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
);
}
}
}
logInfo("==== Step 1: Analyzing intent ====");
let toolCalls;
// Use the original message for intent analysis
const messageForAnalysis = userMessage.originalMessage || userMessage.message;
try {
toolCalls = await IntentAnalyzer.analyzeIntent(messageForAnalysis);
} catch (error: any) {
return this.handleResponse(
getApiErrorMessage(error),
userMessage,
abortController,
addMessage,
updateCurrentAiMessage
);
}
// Use the same removeAtCommands logic as IntentAnalyzer
const cleanedUserMessage = userMessage.message
.split(" ")
.filter((word) => !COPILOT_TOOL_NAMES.includes(word.toLowerCase()))
.join(" ")
.trim();
const toolOutputs = await this.executeToolCalls(toolCalls, updateLoadingMessage);
const localSearchResult = toolOutputs.find(
(output) => output.tool === "localSearch" && output.output && output.output.length > 0
);
// Format chat history from memory
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
if (localSearchResult) {
logInfo("==== Step 2: Processing local search results ====");
const documents = JSON.parse(localSearchResult.output);
logInfo("==== Step 3: Condensing Question ====");
const standaloneQuestion = await getStandaloneQuestion(cleanedUserMessage, chatHistory);
logInfo("Condensed standalone question: ", standaloneQuestion);
logInfo("==== Step 4: Preparing context ====");
const timeExpression = this.getTimeExpression(toolCalls);
const context = this.prepareLocalSearchResult(documents, timeExpression);
const currentTimeOutputs = toolOutputs.filter((output) => output.tool === "getCurrentTime");
const enhancedQuestion = this.prepareEnhancedUserMessage(
standaloneQuestion,
currentTimeOutputs
);
logInfo(context);
logInfo("==== Step 5: Invoking QA Chain ====");
const qaPrompt = await this.chainManager.promptManager.getQAPrompt({
question: enhancedQuestion,
context,
systemMessage: "", // System prompt is added separately in streamMultimodalResponse
});
fullAIResponse = await this.streamMultimodalResponse(
qaPrompt,
userMessage,
abortController,
trackAndUpdateAiMessage
);
// Append sources to the response
sources = this.getSources(documents);
} else {
// Enhance with tool outputs.
const enhancedUserMessage = this.prepareEnhancedUserMessage(
cleanedUserMessage,
toolOutputs
);
// If no results, default to LLM Chain
logInfo("No local search results. Using standard LLM Chain.");
fullAIResponse = await this.streamMultimodalResponse(
enhancedUserMessage,
userMessage,
abortController,
trackAndUpdateAiMessage
);
}
} catch (error: any) {
// Reset loading message to default
updateLoadingMessage?.(LOADING_MESSAGES.DEFAULT);
// Check if the error is due to abort signal
if (error.name === "AbortError" || abortController.signal.aborted) {
logInfo("CopilotPlus stream aborted by user", { reason: abortController.signal.reason });
// Don't show error message for user-initiated aborts
} else {
await this.handleError(error, addMessage, updateCurrentAiMessage);
}
}
// Only skip saving if it's a new chat (clearing everything)
if (abortController.signal.aborted && abortController.signal.reason === ABORT_REASON.NEW_CHAT) {
updateCurrentAiMessage("");
return "";
}
// If aborted but not a new chat, use the partial response
if (abortController.signal.aborted && currentPartialResponse) {
fullAIResponse = currentPartialResponse;
}
return this.handleResponse(
fullAIResponse,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage,
sources
);
}
private getSources(documents: any): { title: string; score: number }[] {
if (!documents || !Array.isArray(documents)) {
logWarn("No valid documents provided to getSources");
return [];
}
return this.sortUniqueDocsByScore(documents);
}
private sortUniqueDocsByScore(documents: any[]): any[] {
const uniqueDocs = new Map<string, any>();
// Iterate through all documents
for (const doc of documents) {
if (!doc.title || (!doc?.score && !doc?.rerank_score)) {
logWarn("Invalid document structure:", doc);
continue;
}
const currentDoc = uniqueDocs.get(doc.title);
const isReranked = doc && "rerank_score" in doc;
const docScore = isReranked ? doc.rerank_score : doc.score;
// If the title doesn't exist in the map, or if the new doc has a higher score, update the map
if (!currentDoc || docScore > (currentDoc.score ?? 0)) {
uniqueDocs.set(doc.title, {
title: doc.title,
score: docScore,
isReranked: isReranked,
});
}
}
// Convert the map values back to an array and sort by score in descending order
return Array.from(uniqueDocs.values()).sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
}
private async executeToolCalls(
toolCalls: any[],
updateLoadingMessage?: (message: string) => void
) {
const toolOutputs = [];
for (const toolCall of toolCalls) {
logInfo(`==== Step 2: Calling tool: ${toolCall.tool.name} ====`);
if (toolCall.tool.name === "localSearch") {
updateLoadingMessage?.(LOADING_MESSAGES.READING_FILES);
} else if (toolCall.tool.name === "webSearch") {
updateLoadingMessage?.(LOADING_MESSAGES.SEARCHING_WEB);
} else if (toolCall.tool.name === "getFileTree") {
updateLoadingMessage?.(LOADING_MESSAGES.READING_FILE_TREE);
}
const output = await ToolManager.callTool(toolCall.tool, toolCall.args);
toolOutputs.push({ tool: toolCall.tool.name, output });
}
return toolOutputs;
}
private prepareEnhancedUserMessage(userMessage: string, toolOutputs: any[]) {
let context = "";
if (toolOutputs.length > 0) {
const validOutputs = toolOutputs.filter((output) => output.output != null);
if (validOutputs.length > 0) {
context =
"\n\n# Additional context:\n\n" +
validOutputs
.map(
(output) =>
`<${output.tool}>\n${typeof output.output !== "string" ? JSON.stringify(output.output) : output.output}\n</${output.tool}>`
)
.join("\n\n");
}
}
return `${userMessage}${context}`;
}
private getTimeExpression(toolCalls: any[]): string {
const timeRangeCall = toolCalls.find((call) => call.tool.name === "getTimeRangeMs");
return timeRangeCall ? timeRangeCall.args.timeExpression : "";
}
private prepareLocalSearchResult(documents: any[], timeExpression: string): string {
// First filter documents with includeInContext
const includedDocs = documents.filter((doc) => doc.includeInContext);
// Calculate total content length
const totalLength = includedDocs.reduce((sum, doc) => sum + doc.content.length, 0);
// If total length exceeds threshold, calculate truncation ratio
let truncatedDocs = includedDocs;
if (totalLength > MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT) {
const truncationRatio = MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT / totalLength;
logInfo("Truncating documents to fit context length. Truncation ratio:", truncationRatio);
truncatedDocs = includedDocs.map((doc) => ({
...doc,
content: doc.content.slice(0, Math.floor(doc.content.length * truncationRatio)),
}));
}
const formattedDocs = truncatedDocs
.map((doc: any) => `Note in Vault: ${doc.content}`)
.join("\n\n");
return timeExpression
? `Local Search Result for ${timeExpression}:\n${formattedDocs}`
: `Local Search Result:\n${formattedDocs}`;
}
protected async getSystemPrompt(): Promise<string> {
return getSystemPrompt();
}
}
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;
}
}
export { CopilotPlusChainRunner, LLMChainRunner, ProjectChainRunner, VaultQAChainRunner };

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

@ -0,0 +1,244 @@
import { ABORT_REASON, AI_SENDER } from "@/constants";
import { logError, logInfo } from "@/logger";
import { ChatMessage, ResponseMetadata } from "@/types/message";
import { err2String, formatDateTime } from "@/utils";
import ChainManager from "../chainManager";
export interface ChainRunner {
run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string>;
}
export abstract class BaseChainRunner implements ChainRunner {
protected chainManager: ChainManager;
constructor(chainManager: ChainManager) {
this.chainManager = chainManager;
}
abstract run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): 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,
abortController: AbortController,
addMessage: (message: ChatMessage) => void,
updateCurrentAiMessage: (message: string) => void,
sources?: { title: string; path: string; score: number }[],
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)
// 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("");
} else if (abortController.signal.reason === ABORT_REASON.NEW_CHAT) {
// Also clear if it's a new chat
updateCurrentAiMessage("");
}
// Log compact memory summary and a truncated final response (~300 chars)
const historyMessages = (
this.chainManager.memoryManager.getMemory().chatHistory as { messages?: unknown[] }
).messages;
logInfo("Chat memory updated:\n", {
turns: Array.isArray(historyMessages) ? historyMessages.length : 0,
});
const MAX_LOG_LENGTH = 2000;
try {
const { parseToolCallMarkers } = await import("./utils/toolCallParser");
const parsed = parseToolCallMarkers(fullAIResponse);
let textOnly = (parsed.segments as { type: string; content: string }[])
.map((seg) => (seg.type === "text" ? seg.content : ""))
.join("")
.trim();
if (!textOnly) textOnly = fullAIResponse || "";
const snippet =
textOnly.length > MAX_LOG_LENGTH
? textOnly.slice(0, MAX_LOG_LENGTH) + "... (truncated)"
: textOnly;
logInfo("Final AI response (truncated):\n", snippet);
} catch {
// Fallback: truncate raw response without parsing
const s = typeof fullAIResponse === "string" ? fullAIResponse : String(fullAIResponse ?? "");
const clipped =
s.length > MAX_LOG_LENGTH ? s.slice(0, MAX_LOG_LENGTH) + "... (truncated)" : s;
logInfo("Final AI response (truncated):\n", clipped);
}
return fullAIResponse;
}
/**
* 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 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 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 =
"You do not have access to this model or the model does not exist, please check with your API provider.";
} else {
errorMessage = `${errorCode}`;
}
logError(errorData);
processErrorChunk(this.enhancedErrorMsg(errorMessage, msg, error));
}
/**
* 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;
// 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

@ -0,0 +1,177 @@
import { ABORT_REASON, ModelCapability } from "@/constants";
import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter";
import { logInfo } from "@/logger";
import { getSettings } from "@/settings/model";
import { ChatMessage } from "@/types/message";
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,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string> {
// 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 {
// 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();
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(
// ProviderMessage[] format matches what getChatModel().stream() accepts at runtime
messages as never,
{
signal: abortController.signal,
}
)
);
for await (const chunk of chatStream) {
if (abortController.signal.aborted) {
logInfo("Stream iteration aborted", { reason: abortController.signal.reason });
break;
}
streamer.processChunk(chunk as Parameters<typeof streamer.processChunk>[0]);
}
} catch (error: unknown) {
// Check if the error is due to abort signal
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,
streamer.processErrorChunk.bind(streamer) as (message: string) => void
);
}
}
// Always return the response, even if partial
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) {
updateCurrentAiMessage("");
return "";
}
await this.handleResponse(
result.content,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage,
undefined,
undefined,
responseMetadata
);
return result.content;
}
}

View file

@ -0,0 +1,11 @@
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 {
// No overrides needed - project context automatically in L1 via ChatManager
}

View file

@ -0,0 +1,708 @@
# Chain Runner Architecture & Tool Calling System
This directory contains the refactored chain runner system for Obsidian Copilot, providing multiple chain execution strategies with different tool calling approaches.
## Overview
The chain runner system provides two distinct tool calling approaches:
1. **Copilot Plus** (CopilotPlusChainRunner) - Uses native tool calling for intent analysis
2. **Autonomous Agent** (AutonomousAgentChainRunner) - Uses native LangChain tool calling with ReAct pattern
## Architecture
```
chainRunner/
├── BaseChainRunner.ts # Abstract base class with shared functionality
├── LLMChainRunner.ts # Basic LLM interaction (no tools)
├── VaultQAChainRunner.ts # Vault-only Q&A with retrieval
├── CopilotPlusChainRunner.ts # Legacy tool calling system
├── ProjectChainRunner.ts # Project-aware extension of Plus
├── AutonomousAgentChainRunner.ts # Native tool calling with ReAct agent loop
├── index.ts # Main exports
└── utils/
├── ThinkBlockStreamer.ts # Handles thinking content from models
├── xmlParsing.ts # XML escape/unescape utilities (for context envelope)
├── toolExecution.ts # Tool execution helpers
└── modelAdapter.ts # Model-specific adaptations
```
## Tool Calling Systems Comparison
### 1. Model-Based Tool Planning (CopilotPlusChainRunner)
**How it works:**
- 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 → Model Planning → Tool Execution → Enhanced Prompt → LLM Response
```
**Example:**
```typescript
// 1. Plan tools using model
const { toolCalls, salientTerms } = await this.planToolCalls(message, chatModel);
// 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);
// 4. Send to LLM
const response = await this.streamMultimodalResponse(message, toolOutputs, ...);
```
**Tools Available:**
- `localSearch` - Search vault content
- `webSearch` - Search the web
- `getCurrentTime` - Get current time
- `getFileTree` - Get file structure
- `pomodoroTool` - Pomodoro timer
- `youtubeTranscription` - YouTube video transcription
### 2. Autonomous Agent (AutonomousAgentChainRunner)
**How it works:**
- 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 via `ToolMessage`
**Flow:**
```
User Message → AI Reasoning → tool_calls → Tool Execution →
ToolMessage → AI Analysis → More Tools? → Final Response
```
**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 with potential tool calls
const response = await boundModel.invoke(messages);
messages.push(response);
// 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 and add ToolMessage
for (const toolCall of response.tool_calls) {
const result = await executeSequentialToolCall(toolCall, availableTools);
messages.push(
new ToolMessage({
content: JSON.stringify(result),
tool_call_id: toolCall.id,
name: toolCall.name,
})
);
}
// 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 | 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 |
## LangChain Tool Interface
### Overview
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
// Tool creation returns a LangChain StructuredTool
const myTool = createLangChainTool({
name: string;
description: string;
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 `createLangChainTool` with Zod schemas:
#### Tool with No Parameters
```typescript
const indexTool = createLangChainTool({
name: "indexVault",
description: "Index the vault to the Copilot index",
schema: z.object({}), // Empty object for no parameters
func: async () => {
// Tool implementation
return { status: "complete" };
},
});
// Register with metadata
registry.register({
tool: indexTool,
metadata: {
id: "indexVault",
displayName: "Index Vault",
description: "Index the vault",
category: "file",
isBackground: true,
},
});
```
#### Tool with Parameters
```typescript
// Define schema with validation rules
const searchSchema = z.object({
query: z.string().min(1).describe("The search query"),
salientTerms: z.array(z.string()).min(1).describe("Key terms extracted from query"),
timeRange: z
.object({
startTime: z.any(),
endTime: z.any(),
})
.optional()
.describe("Time range for search"),
});
// Create tool with automatic validation
const searchTool = createLangChainTool({
name: "localSearch",
description: "Search for notes based on query and time range",
schema: searchSchema,
func: async ({ query, salientTerms, timeRange }) => {
// Handler receives fully typed and validated arguments
return performSearch(query, salientTerms, timeRange);
},
});
// Register with metadata
registry.register({
tool: searchTool,
metadata: {
id: "localSearch",
displayName: "Vault Search",
category: "search",
timeoutMs: 30000,
},
});
```
### Benefits of LangChain Native Tools
1. **Type Safety**: Full TypeScript type inference from Zod schemas
2. **Runtime Validation**: All inputs validated before reaching handler
3. **Native LangChain Integration**: Compatible with `bindTools()` and LangChain tooling ecosystem
4. **Better Error Messages**: Zod provides detailed validation errors
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
#### Complex Validation
```typescript
const emailToolSchema = z.object({
to: z.string().email().describe("Recipient email"),
subject: z.string().min(1).max(100).describe("Email subject"),
body: z.string().min(1).describe("Email content"),
cc: z.array(z.string().email()).optional().describe("CC recipients"),
});
```
#### Union Types for Actions
```typescript
const actionSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("search"),
query: z.string().min(1),
}),
z.object({
type: z.literal("create"),
content: z.string().min(1),
tags: z.array(z.string()).default([]),
}),
z.object({
type: z.literal("delete"),
id: z.string().uuid(),
}),
]);
const actionTool = createLangChainTool({
name: "performAction",
description: "Perform various actions",
schema: actionSchema,
func: async (action) => {
// TypeScript knows exactly which type based on discriminator
switch (action.type) {
case "search":
return search(action.query);
case "create":
return create(action.content, action.tags);
case "delete":
return deleteItem(action.id);
}
},
});
```
#### Custom Validation
```typescript
const filePathSchema = z
.string()
.refine((val) => val.endsWith(".md") || val.endsWith(".canvas"), {
message: "File must be .md or .canvas",
})
.refine((val) => !val.includes(".."), { message: "Path traversal not allowed" })
.describe("Path to markdown or canvas file");
```
#### Transformations
```typescript
const dateToolSchema = z.object({
date: z
.string()
.describe("Date in ISO format or natural language")
.transform((str) => new Date(str)),
timezone: z.string().default("UTC").describe("Timezone identifier"),
});
```
### Schema Composition
```typescript
// Base schemas that can be reused
const timeRangeSchema = z
.object({
startTime: z.date(),
endTime: z.date(),
})
.refine((data) => data.endTime > data.startTime, {
message: "End time must be after start time",
});
const paginationSchema = z.object({
page: z.number().int().positive().default(1),
pageSize: z.number().int().positive().max(100).default(20),
});
// Compose into larger schemas
const searchWithPaginationSchema = z.object({
query: z.string().min(1).describe("Search query"),
filters: z.record(z.string()).optional().describe("Additional filters"),
timeRange: timeRangeSchema.optional(),
pagination: paginationSchema,
});
```
### Default Values
```typescript
const configSchema = z.object({
temperature: z.number().min(0).max(2).default(0.7),
maxTokens: z.number().int().positive().default(1000),
model: z.enum(["gpt-4", "gpt-3.5-turbo"]).default("gpt-4"),
});
// Handler receives object with defaults applied
const configTool = createLangChainTool({
name: "updateConfig",
schema: configSchema,
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)
return updateConfiguration(config);
},
});
```
### Handling Validation Errors with Retry
When AI-generated parameters fail Zod validation, the tool execution will return a formatted error. The autonomous agent automatically handles this through its iterative loop:
```typescript
// Example tool with strict validation
const searchToolWithValidation = createLangChainTool({
name: "searchNotes",
description: "Search notes with specific criteria",
schema: z.object({
query: z.string().min(2, "Query must be at least 2 characters"),
limit: z.number().int().min(1).max(100),
sortBy: z.enum(["relevance", "date", "title"]),
}),
func: async ({ query, limit, sortBy }) => {
return performSearch(query, limit, sortBy);
},
});
// When the AI provides invalid parameters:
// Input: { query: "a", limit: 200, sortBy: "random" }
//
// The flow:
// 1. Tool execution catches Zod validation error
// 2. Returns: "Tool searchNotes validation failed: query: Query must be at least 2 characters,
// limit: Number must be less than or equal to 100, sortBy: Invalid enum value"
// 3. This error is added to the conversation as a user message
// 4. The AI sees the error in the next iteration and can retry with corrected parameters
// 5. The autonomous agent continues up to 4 iterations, allowing multiple retry attempts
// Example conversation flow:
// Iteration 1: AI calls tool with invalid params → receives error
// Iteration 2: AI understands error and retries with { query: "search term", limit: 50, sortBy: "date" } → success
```
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.
## Native Tool Calling Details
### Tool Execution (`toolExecution.ts`)
```typescript
// Execute individual tool with timeout and error handling
async function executeSequentialToolCall(
toolCall: ToolCall,
availableTools: any[]
): Promise<ToolExecutionResult> {
// 30-second timeout per tool
// 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 Agent Mode
All tools from the Copilot Plus system plus autonomous decision-making:
- **localSearch** - Vault content search with salient terms and query expansion
- **webSearch** - Web search with chat history context
- **getFileTree** - File structure exploration
- **getCurrentTime** / **getTimeRangeMs** - Time-based queries
- **pomodoroTool** - Productivity timer
- **indexTool** - Vault indexing operations
- **youtubeTranscription** - Video content analysis
### System Prompt Engineering
The Autonomous Agent mode uses a comprehensive system prompt that:
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)
Tool descriptions are provided automatically via Zod schemas. Model adapters add behavioral guidance:
```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.
`;
}
```
## Benefits of Autonomous Agent
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 supports native tool calling
5. **No External Dependencies** - No Brevilabs API required
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
### Enable Autonomous Agent
```typescript
// In settings
settings.enableAutonomousAgent = true;
// ChainManager automatically selects the appropriate runner
const runner = chainManager.getChainRunner(); // Returns AutonomousAgentChainRunner
```
### Example Query Flow
**User Input:** "Find my notes about machine learning and research current best practices"
**Autonomous Agent Process:**
1. **Iteration 1**: AI reasons about the task → calls `localSearch` for ML notes
2. **Iteration 2**: Analyzes vault results → calls `webSearch` for current practices
3. **Iteration 3**: Synthesizes both sources → provides comprehensive response
**Legacy Process:**
1. Intent analysis determines both tools needed
2. Executes both tools
3. Single LLM call with all context
## Error Handling & Fallbacks
### Autonomous Agent Fallbacks
```typescript
try {
// Sequential thinking execution
} catch (error) {
// Automatic fallback to CopilotPlusChainRunner
const fallbackRunner = new CopilotPlusChainRunner(this.chainManager);
return await fallbackRunner.run(/* same parameters */);
}
```
### Tool Execution Safeguards
- 30-second timeout per tool
- Graceful error handling with descriptive messages
- Tool availability validation
- Result validation and formatting
## Model Adapter Pattern
### Overview
The Model Adapter pattern handles model-specific quirks and requirements cleanly, keeping the core logic model-agnostic.
### Architecture
```typescript
interface ModelAdapter {
enhanceSystemPrompt(basePrompt: string, toolDescriptions: string): string;
enhanceUserMessage(message: string, requiresTools: boolean): string;
needsSpecialHandling(): boolean;
}
```
> **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
2. **GPTModelAdapter** - Aggressive prompting for GPT models that often skip tool calls
3. **ClaudeModelAdapter** - Specialized handling for Claude thinking models (3.7 Sonnet, Claude 4)
4. **GeminiModelAdapter** - Ready for Gemini-specific handling
### Adding a New Model
```typescript
class NewModelAdapter extends BaseModelAdapter {
enhanceSystemPrompt(basePrompt: string, toolDescriptions: string): string {
const base = super.enhanceSystemPrompt(basePrompt, toolDescriptions);
return base + "\n\n[Model-specific instructions here]";
}
enhanceUserMessage(message: string, requiresTools: boolean): string {
// Add model-specific hints if needed
return requiresTools ? `${message}\n[Model-specific hint]` : message;
}
}
// Register in ModelAdapterFactory
if (modelName.includes("newmodel")) {
return new NewModelAdapter(modelName);
}
```
### Claude Model Adapter Features
The `ClaudeModelAdapter` includes specialized handling for Claude thinking models:
#### Thinking Model Support
- **Claude 3.7 Sonnet** and **Claude 4** - Automatic thinking mode configuration
- **Think Block Preservation** - Maintains valuable reasoning context in responses
- **Temperature Control** - Disables temperature for thinking models (as required by API)
> **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.
#### Agent Reasoning Block
The reasoning process is now displayed via the Agent Reasoning Block component, which shows:
```
⏱️ 2.3s elapsed
├─ Searching notes for "piano", "learning", "practice"...
├─ Found 5 notes: Piano Practice.md, Learning Music.md...
└─ Generating response...
```
### Benefits
1. **Separation of Concerns** - Model quirks isolated from core logic
2. **Maintainability** - Easy to find and update model-specific code
3. **Extensibility** - Simple to add support for new models
4. **Testing** - Model adapters can be unit tested independently
5. **Clean Core** - Autonomous agent logic remains model-agnostic
6. **Hallucination Prevention** - Specialized handling for problematic models
7. **Streaming Protection** - Prevents bad content from reaching users
8. **Generalizable Solutions** - Uses threshold-based detection over regex patterns
## Future Considerations
1. **Tool Discovery** - Dynamic tool registration
2. **Custom Tools** - User-defined tool capabilities
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 (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
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

@ -0,0 +1,324 @@
import { ABORT_REASON, ModelCapability, RETRIEVED_DOCUMENT_TAG } from "@/constants";
import { getStandaloneQuestion } from "@/chainUtils";
import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter";
import { logInfo } from "@/logger";
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,
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(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string> {
// 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
// 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);
// 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) {
logInfo("[VaultQA] Condensing query with chat history for better retrieval");
standaloneQuestion = await getStandaloneQuestion(rawUserQuery, chatHistory);
logInfo("[VaultQA] Standalone question:", standaloneQuestion);
}
// Step 5: Create retriever based on semantic search setting
const settings = getSettings();
// 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);
// Format documents as context with XML tags
// Sanitize content to remove pre-existing citation markers
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}>`;
})
.join("\n\n");
// Step 6: Build messages array with envelope-aware logic
const messages: { role: string; content: string | unknown[] }[] = [];
const chatModel = this.chainManager.chatModelManager.getChatModel();
// 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) => ({
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 +
getQACitationInstructions(sourceCatalog, settings.enableInlineCitations) +
capNotice;
// 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: systemMessage.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,
});
}
}
// 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(
// ProviderMessage[] format matches what getChatModel().stream() accepts at runtime
messages as never,
{
signal: abortController.signal,
}
)
);
for await (const chunk of chatStream) {
if (abortController.signal.aborted) {
logInfo("VaultQA stream iteration aborted", { reason: abortController.signal.reason });
break;
}
streamer.processChunk(chunk as Parameters<typeof streamer.processChunk>[0]);
}
} catch (error: unknown) {
// Check if the error is due to abort signal
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,
streamer.processErrorChunk.bind(streamer) as (message: string) => void
);
}
}
// Always get the response, even if partial
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) {
updateCurrentAiMessage("");
return "";
}
// Add sources to the response
const fullAIResponse = this.addSourcestoResponse(result.content);
await this.handleResponse(
fullAIResponse,
userMessage,
abortController,
addMessage,
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

@ -0,0 +1,7 @@
// Main exports for chain runners
export type { ChainRunner } from "./BaseChainRunner";
export { LLMChainRunner } from "./LLMChainRunner";
export { VaultQAChainRunner } from "./VaultQAChainRunner";
export { CopilotPlusChainRunner } from "./CopilotPlusChainRunner";
export { ProjectChainRunner } from "./ProjectChainRunner";
export { AutonomousAgentChainRunner } from "./AutonomousAgentChainRunner";

View file

@ -0,0 +1,232 @@
import { ActionBlockStreamer } from "./ActionBlockStreamer";
import { ToolManager } from "@/tools/toolManager";
import { ToolResultFormatter } from "@/tools/ToolResultFormatter";
// Mock the ToolManager and ToolResultFormatter
jest.mock("@/tools/toolManager");
jest.mock("@/tools/ToolResultFormatter");
const MockedToolManager = ToolManager as jest.Mocked<typeof ToolManager>;
const MockedToolResultFormatter = ToolResultFormatter as jest.Mocked<typeof ToolResultFormatter>;
describe("ActionBlockStreamer", () => {
let writeFileTool: unknown;
let streamer: ActionBlockStreamer;
beforeEach(() => {
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, writeFileTool);
});
// Helper function to process chunks and collect results
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
outputContents.push(result.content);
}
}
return outputContents;
}
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);
// All chunks should be yielded as-is
expect(output).toEqual(["Hello ", "world, this is ", "a test."]);
// No tool calls should be made
expect(MockedToolManager.callTool).not.toHaveBeenCalled();
});
it("should handle a complete writeFile block in a single chunk", async () => {
MockedToolManager.callTool.mockResolvedValue("File written successfully.");
const chunks = [
{
content:
"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 <writeFile><path>file.txt</path><content>content</content></writeFile> and some text after.",
"\nFile written successfully.\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "file.txt",
content: "content",
});
});
it("should handle a complete xml-wrapped writeFile block", async () => {
MockedToolManager.callTool.mockResolvedValue("XML file written.");
const chunks = [
{
content:
"```xml\n<writeFile><path>file.xml</path><content>xml content</content></writeFile>\n```",
},
];
const output = await processChunks(chunks);
expect(output).toEqual([
"```xml\n<writeFile><path>file.xml</path><content>xml content</content></writeFile>\n```",
"\nXML file written.\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "file.xml",
content: "xml content",
});
});
it("should handle a writeFile block split across multiple chunks", async () => {
MockedToolManager.callTool.mockResolvedValue("Split file written.");
const chunks = [
{ content: "Here is a file <writeFile><path>split.txt</path>" },
{ content: "<content>split content</content>" },
{ 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 <writeFile><path>split.txt</path>",
"<content>split content</content>",
"</writeFile> That was it.",
"\nSplit file written.\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "split.txt",
content: "split content",
});
});
it("should handle multiple writeFile blocks in the stream", async () => {
MockedToolManager.callTool
.mockResolvedValueOnce("File 1 written.")
.mockResolvedValueOnce("File 2 written.");
const chunks = [
{
content:
"<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([
"<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(writeFileTool, {
path: "f1.txt",
content: "c1",
});
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "f2.txt",
content: "c2",
});
});
it("should handle unclosed tags without calling tools", async () => {
const chunks = [
{ 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... <writeFile><path>unclosed.txt</path>",
"<content>this will not be closed",
]);
// No tool calls should be made for incomplete blocks
expect(MockedToolManager.callTool).not.toHaveBeenCalled();
});
it("should handle tool call errors gracefully", async () => {
MockedToolManager.callTool.mockRejectedValue(new Error("Tool error"));
const chunks = [
{
content: "<writeFile><path>error.txt</path><content>content</content></writeFile>",
},
];
const output = await processChunks(chunks);
expect(output).toEqual([
"<writeFile><path>error.txt</path><content>content</content></writeFile>",
"\nError: Tool error\n",
]);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "error.txt",
content: "content",
});
});
it("should handle chunks with different content types", async () => {
const chunks: { content: string | null }[] = [
{ content: "Hello" },
{ content: null },
{ content: "" },
{ content: " World" },
];
const output = await processChunks(chunks);
// Should yield all chunks as-is, null content is yielded but not added to buffer
expect(output).toEqual(["Hello", null, "", " World"]);
});
it("should handle whitespace in path and content", async () => {
MockedToolManager.callTool.mockResolvedValue("Whitespace handled.");
const chunks = [
{
content:
"<writeFile><path> spaced.txt </path><content> content with spaces </content></writeFile>",
},
];
await processChunks(chunks);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "spaced.txt",
content: "content with spaces",
});
});
it("should handle malformed blocks gracefully", async () => {
MockedToolManager.callTool.mockResolvedValue("Malformed handled.");
const chunks = [
{
content: "<writeFile><path>missing-content.txt</path></writeFile>",
},
];
const output = await processChunks(chunks);
// Should yield chunk as-is plus tool result
expect(output).toEqual([
"<writeFile><path>missing-content.txt</path></writeFile>",
"\nMalformed handled.\n",
]);
// Tool should be called with undefined content
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "missing-content.txt",
content: undefined,
});
});
});

View file

@ -0,0 +1,95 @@
import { ToolManager } from "@/tools/toolManager";
import { ToolResultFormatter } from "@/tools/ToolResultFormatter";
/**
* ActionBlockStreamer processes streaming chunks to detect and handle writeFile blocks.
*
* 1. Accumulates chunks in a buffer
* 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 {
private buffer = "";
constructor(
private toolManager: typeof ToolManager,
private writeFileTool: unknown
) {}
private findCompleteBlock(str: string) {
// Regex for both formats
const regex = /<writeFile>[\s\S]*?<\/writeFile>/;
const match = str.match(regex);
if (!match || match.index === undefined) {
return null;
}
return {
block: match[0],
endIdx: match.index + match[0].length,
};
}
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 as Array<{ type?: string; text?: unknown }>) {
if (item.type === "text" && item.text != null) {
chunkContent += typeof item.text === "string" ? item.text : "";
}
}
}
// Handle standard string content
else if (chunk.content != null) {
chunkContent = typeof chunk.content === "string" ? chunk.content : "";
}
// Add to buffer
if (chunkContent) {
this.buffer += chunkContent;
}
// Yield the original chunk as-is
yield chunk;
// Process all complete blocks in the buffer
let blockInfo = this.findCompleteBlock(this.buffer);
while (blockInfo) {
const { block, endIdx } = blockInfo;
// Extract content from the block
const pathMatch = block.match(/<path>([\s\S]*?)<\/path>/);
const contentMatch = block.match(/<content>([\s\S]*?)<\/content>/);
const filePath = pathMatch ? pathMatch[1].trim() : undefined;
const fileContent = contentMatch ? contentMatch[1].trim() : undefined;
// Call the tool
try {
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("writeFile", result as string);
yield { ...chunk, content: `\n${formattedResult}\n` };
} catch (err: unknown) {
yield { ...chunk, content: `\nError: ${(err as Error)?.message ?? String(err)}\n` };
}
// Remove processed block from buffer
this.buffer = this.buffer.substring(endIdx);
// Check for another complete block in the remaining buffer
blockInfo = this.findCompleteBlock(this.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");
});
});
});

Some files were not shown because too many files have changed in this diff Show more