Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/0xatrilla_ai-sidebar.json
T

1 line
403 KiB
JSON

{"repo": "0xatrilla/ai-sidebar", "files": {"main.ts": {"6014d208faad0e9b52ab8fe991a38d90cdf82960": {"t": 1785746043.206143, "n": 67989, "h": "7d4d944d17e7880c", "c": "import {\n App,\n ButtonComponent,\n DropdownComponent,\n ItemView,\n MarkdownView,\n Modal,\n Notice,\n Plugin,\n PluginSettingTab,\n requestUrl,\n Setting,\n TFile,\n WorkspaceLeaf,\n} from \"obsidian\";\nimport { spawn } from \"child_process\";\nimport { promises as fs } from \"fs\";\nimport { homedir } from \"os\";\nimport * as path from \"path\";\n\nconst VIEW_TYPE_AI_SIDEBAR = \"ai-sidebar-view\";\n\ntype AccessMode = \"read-only\" | \"confirm\" | \"full-access\";\ntype ProviderAuthType = \"api-key\" | \"oauth\" | \"cli\";\ntype ReasoningEffort = \"low\" | \"medium\" | \"high\";\n\ninterface AgentProvider {\n id: string;\n name: string;\n authType: ProviderAuthType;\n command: string;\n signInCommand?: string;\n setupUrl?: string;\n connectedAt?: number;\n apiBaseUrl?: string;\n apiKey?: string;\n model?: string;\n models?: string;\n reasoningEffort?: ReasoningEffort;\n authorizationUrl?: string;\n tokenUrl?: string;\n clientId?: string;\n scope?: string;\n accessToken?: string;\n refreshToken?: string;\n tokenExpiresAt?: number;\n}\n\ninterface AISidebarSettings {\n providers: AgentProvider[];\n defaultProviderId: string;\n defaultAccessMode: AccessMode;\n includeFolders: string;\n excludeFolders: string;\n maxContextChars: number;\n enableConversationMemory: boolean;\n maxMemoryMessages: number;\n rememberedMessages: ChatMessage[];\n}\n\ninterface VaultContextFile {\n path: string;\n content: string;\n reason: string;\n}\n\ninterface AgentSkill {\n name: string;\n path: string;\n content: string;\n}\n\ninterface SkillReference {\n name: string;\n path: string;\n source: \"vault\" | \"global\";\n}\n\ninterface VaultContext {\n activeFile?: VaultContextFile;\n selection?: string;\n linkedFiles: VaultContextFile[];\n backlinkFiles: VaultContextFile[];\n relevantFiles: VaultContextFile[];\n baseFiles: VaultContextFile[];\n selectedSkills: AgentSkill[];\n}\n\ninterface ChatMessage {\n role: \"user\" | \"assistant\" | \"system\";\n content: string;\n}\n\ninterface AgentRequest {\n prompt: string;\n accessMode: AccessMode;\n context: VaultContext;\n instructions: string;\n conversation: ChatMessage[];\n options: AgentRequestOptions;\n}\n\ninterface AgentRequestOptions {\n model?: string;\n reasoningEffort?: ReasoningEffort;\n memoryEnabled: boolean;\n}\n\ninterface ParsedPrompt {\n cleanPrompt: string;\n selectedSkillNames: string[];\n options: AgentRequestOptions;\n accessMode?: AccessMode;\n}\n\ntype VaultAction =\n | { type: \"create\"; path: string; content: string }\n | { type: \"edit\"; path: string; content: string }\n | { type: \"append\"; path: string; content: string }\n | { type: \"delete\"; path: string }\n | { type: \"rename\"; path: string; newPath: string };\n\ninterface ProviderPreset {\n id: string;\n name: string;\n description: string;\n provider: AgentProvider;\n}\n\nconst PROVIDER_PRESETS: ProviderPreset[] = [\n {\n id: \"codex\",\n name: \"Codex\",\n description: \"Sign in with Codex locally and use the Codex CLI agent.\",\n provider: {\n id: \"codex\",\n name: \"Codex\",\n authType: \"cli\",\n command: \"codex exec\",\n signInCommand: \"codex login\",\n models: \"gpt-5.4, gpt-5.4-mini, gpt-5.3-codex\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"claude\",\n name: \"Claude Code\",\n description: \"Sign in with your Claude subscription or Anthropic Console account.\",\n provider: {\n id: \"claude\",\n name: \"Claude Code\",\n authType: \"cli\",\n command: \"claude\",\n signInCommand: \"claude auth login\",\n setupUrl: \"https://code.claude.com/docs/en/cli-usage\",\n models: \"opus, sonnet, haiku\",\n },\n },\n {\n id: \"opencode\",\n name: \"opencode\",\n description: \"Connect opencode to any provider it supports, including subscription/API accounts.\",\n provider: {\n id: \"opencode\",\n name: \"opencode\",\n authType: \"cli\",\n command: \"opencode\",\n signInCommand: \"opencode auth login\",\n setupUrl: \"https://opencode.ai/docs/cli/\",\n models: \"anthropic/claude-sonnet-4-5, openai/gpt-5.4, google/gemini-2.5-pro\",\n },\n },\n {\n id: \"openai\",\n name: \"OpenAI API\",\n description: \"Use an OpenAI API key with the Responses API.\",\n provider: {\n id: \"openai\",\n name: \"OpenAI\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.openai.com/api-keys\",\n apiBaseUrl: \"https://api.openai.com/v1/responses\",\n model: \"gpt-4.1-mini\",\n models: \"gpt-4.1-mini, gpt-4.1, o4-mini\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n description: \"Use an Anthropic API key directly through an OpenAI-compatible proxy endpoint.\",\n provider: {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.anthropic.com/settings/keys\",\n apiBaseUrl: \"\",\n model: \"claude-sonnet-4-5\",\n models: \"claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5\",\n },\n },\n {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n description: \"Use Google Gemini CLI with its native sign-in and subscription/API setup.\",\n provider: {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n authType: \"cli\",\n command: \"gemini\",\n signInCommand: \"gemini\",\n setupUrl: \"https://google-gemini.github.io/gemini-cli/docs/\",\n models: \"gemini-2.5-pro, gemini-2.5-flash\",\n },\n },\n {\n id: \"openrouter\",\n name: \"OpenRouter\",\n description: \"Use one API key for many model providers through an OpenAI-compatible endpoint.\",\n provider: {\n id: \"openrouter\",\n name: \"OpenRouter\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://openrouter.ai/settings/keys\",\n apiBaseUrl: \"https://openrouter.ai/api/v1/chat/completions\",\n model: \"openai/gpt-4.1-mini\",\n models: \"openai/gpt-4.1-mini, anthropic/claude-sonnet-4.5, google/gemini-2.5-pro\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"deepseek\",\n name: \"DeepSeek\",\n description: \"Use DeepSeek with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.deepseek.com/api_keys\",\n apiBaseUrl: \"https://api.deepseek.com/v1/chat/completions\",\n model: \"deepseek-chat\",\n models: \"deepseek-chat, deepseek-reasoner\",\n },\n },\n {\n id: \"mistral\",\n name: \"Mistral\",\n description: \"Use Mistral models with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"mistral\",\n name: \"Mistral\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.mistral.ai/api-keys\",\n apiBaseUrl: \"https://api.mistral.ai/v1/chat/completions\",\n model: \"mistral-large-latest\",\n models: \"mistral-large-latest, codestral-latest, ministral-8b-latest\",\n },\n },\n {\n id: \"aider\",\n name: \"Aider\",\n description: \"Use Aider with keys from OpenAI, Anthropic, Gemini, OpenRouter, DeepSeek, and more.\",\n provider: {\n id: \"aider\",\n name: \"Aider\",\n authType: \"cli\",\n command: \"aider\",\n signInCommand: \"aider\",\n setupUrl: \"https://aider.chat/docs/config/api-keys.html\",\n models: \"sonnet, opus, gpt-4.1, o4-mini, gemini/gemini-2.5-pro\",\n },\n },\n];\n\nconst DEFAULT_SETTINGS: AISidebarSettings = {\n providers: [\n cloneProvider(PROVIDER_PRESETS[0].provider),\n cloneProvider(PROVIDER_PRESETS[3].provider),\n ],\n defaultProviderId: \"codex\",\n defaultAccessMode: \"confirm\",\n includeFolders: \"\",\n excludeFolders: \"node_modules, .git\",\n maxContextChars: 45000,\n enableConversationMemory: true,\n maxMemoryMessages: 30,\n rememberedMessages: [],\n};\n\nexport default class AISidebarPlugin extends Plugin {\n settings: AISidebarSettings;\n\n async onload() {\n await this.loadSettings();\n\n this.registerView(\n VIEW_TYPE_AI_SIDEBAR,\n (leaf) => new AISidebarView(leaf, this),\n );\n\n this.addRibbonIcon(\"sparkles\", \"Toggle AI sidebar\", () => {\n void this.toggleSidebar();\n });\n\n this.addCommand({\n id: \"toggle-sidebar\",\n name: \"Toggle sidebar\",\n callback: () => {\n void this.toggleSidebar();\n },\n });\n\n this.addSettingTab(new AISidebarSettingTab(this.app, this));\n }\n\n async toggleSidebar() {\n const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AI_SIDEBAR).first();\n if (existingLeaf) {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n return;\n }\n\n const leaf = this.app.workspace.getRightLeaf(false);\n if (!leaf) {\n new Notice(\"Could not open the AI sidebar.\");\n return;\n }\n\n await leaf.setViewState({ type: VIEW_TYPE_AI_SIDEBAR, active: true });\n await this.app.workspace.revealLeaf(leaf);\n }\n\n async loadSettings() {\n const loaded = await this.loadData() as Partial<AISidebarSettings> | null;\n this.settings = { ...DEFAULT_SETTINGS, ...(loaded ?? {}) };\n this.settings.providers = normalizeProviders(this.settings.providers);\n this.settings.rememberedMessages = Array.isArray(this.settings.rememberedMessages)\n ? this.settings.rememberedMessages\n : [];\n }\n\n async saveSettings() {\n await this.saveData(this.settings);\n }\n\n getProvider(id: string): AgentProvider | undefined {\n return this.settings.providers.find((provider) => provider.id === id);\n }\n\n async rememberMessages(messages: ChatMessage[]) {\n if (!this.settings.enableConversationMemory) return;\n this.settings.rememberedMessages = messages.slice(-this.settings.maxMemoryMessages);\n await this.saveSettings();\n }\n\n async clearMemory() {\n this.settings.rememberedMessages = [];\n await this.saveSettings();\n }\n\n async upsertProviderFromPreset(preset: ProviderPreset) {\n const provider = cloneProvider(preset.provider);\n const existingIndex = this.settings.providers.findIndex((item) => item.id === provider.id);\n if (existingIndex >= 0) {\n this.settings.providers[existingIndex] = {\n ...provider,\n apiKey: this.settings.providers[existingIndex].apiKey,\n accessToken: this.settings.providers[existingIndex].accessToken,\n refreshToken: this.settings.providers[existingIndex].refreshToken,\n };\n } else {\n this.settings.providers.push(provider);\n }\n this.settings.defaultProviderId = provider.id;\n await this.saveSettings();\n }\n}\n\nclass AISidebarView extends ItemView {\n private plugin: AISidebarPlugin;\n private messages: ChatMessage[] = [];\n private providerId: string;\n private accessMode: AccessMode;\n private threadEl: HTMLElement;\n private textareaEl: HTMLTextAreaElement;\n private contextEl: HTMLElement;\n private slashEl: HTMLElement;\n private modelBadgeEl: HTMLElement;\n private activeContextPath = \"\";\n private refreshTimer: number | undefined;\n private sendButton: ButtonComponent;\n private isRunning = false;\n\n constructor(leaf: WorkspaceLeaf, plugin: AISidebarPlugin) {\n super(leaf);\n this.plugin = plugin;\n this.messages = plugin.settings.enableConversationMemory\n ? [...plugin.settings.rememberedMessages]\n : [];\n this.providerId = plugin.settings.defaultProviderId;\n this.accessMode = plugin.settings.defaultAccessMode;\n }\n\n getViewType(): string {\n return VIEW_TYPE_AI_SIDEBAR;\n }\n\n getDisplayText(): string {\n return \"AI sidebar\";\n }\n\n getIcon(): string {\n return \"sparkles\";\n }\n\n async onOpen() {\n this.render();\n this.registerEvent(\n this.app.workspace.on(\"active-leaf-change\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.workspace.on(\"file-open\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.vault.on(\"modify\", (file) => {\n if (file instanceof TFile && file.path === this.activeContextPath) {\n this.scheduleContextRefresh();\n }\n }),\n );\n await this.refreshContextPreview();\n }\n\n onClose(): Promise<void> {\n this.contentEl.empty();\n return Promise.resolve();\n }\n\n private render() {\n this.contentEl.empty();\n this.contentEl.addClass(\"ai-sidebar\");\n\n const header = this.contentEl.createDiv(\"ai-sidebar__header\");\n header.createDiv({ cls: \"ai-sidebar__title\", text: \"AI sidebar\" });\n header.createDiv({ cls: \"ai-sidebar__subtitle\", text: \"Vault-aware agents\" });\n\n const controls = this.contentEl.createDiv(\"ai-sidebar__controls\");\n new DropdownComponent(controls)\n .addOptions(this.providerOptions())\n .setValue(this.providerId)\n .onChange((value) => {\n this.providerId = value;\n this.updateModelBadge();\n });\n\n new DropdownComponent(controls)\n .addOptions({\n \"read-only\": \"Read only\",\n confirm: \"Confirm actions\",\n \"full-access\": \"Full access\",\n })\n .setValue(this.accessMode)\n .onChange((value: AccessMode) => {\n this.accessMode = value;\n void this.refreshContextPreview();\n });\n\n this.contextEl = this.contentEl.createDiv(\"ai-sidebar__context\");\n\n this.threadEl = this.contentEl.createDiv(\"ai-sidebar__thread\");\n this.renderMessages();\n\n const composer = this.contentEl.createDiv(\"ai-sidebar__composer\");\n this.textareaEl = composer.createEl(\"textarea\", {\n cls: \"ai-sidebar__input\",\n attr: {\n placeholder: \"Ask about your vault, draft a note, or request an edit...\",\n rows: \"4\",\n },\n });\n\n this.textareaEl.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Escape\" && !this.slashEl.hasClass(\"is-hidden\")) {\n event.preventDefault();\n this.hideSlashCommands();\n return;\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n void this.sendPrompt();\n }\n });\n this.textareaEl.addEventListener(\"input\", () => {\n void this.updateSlashCommands();\n this.updateModelBadge();\n });\n\n this.slashEl = composer.createDiv(\"ai-sidebar__slash is-hidden\");\n\n const actions = composer.createDiv(\"ai-sidebar__composer-actions\");\n new ButtonComponent(actions)\n .setButtonText(\"Refresh context\")\n .onClick(() => {\n void this.refreshContextPreview();\n });\n new ButtonComponent(actions)\n .setButtonText(\"Clear\")\n .onClick(() => {\n void this.clearConversation();\n });\n\n this.modelBadgeEl = actions.createDiv(\"ai-sidebar__model-badge\");\n this.updateModelBadge();\n\n this.sendButton = new ButtonComponent(actions)\n .setCta()\n .setButtonText(\"Send\")\n .onClick(() => {\n void this.sendPrompt();\n });\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderMessages() {\n this.threadEl.empty();\n\n if (this.messages.length === 0) {\n const empty = this.threadEl.createDiv(\"ai-sidebar__empty\");\n empty.createDiv({ text: \"Ask a local agent to work with the notes in this vault.\" });\n empty.createDiv({ text: \"Use confirm mode when you want to review file edits first.\" });\n return;\n }\n\n for (const message of this.messages) {\n const messageEl = this.threadEl.createDiv(`ai-sidebar__message ai-sidebar__message--${message.role}`);\n messageEl.createDiv({ cls: \"ai-sidebar__message-role\", text: message.role });\n messageEl.createEl(\"pre\", { text: message.content });\n }\n\n if (this.isRunning) {\n const thinkingEl = this.threadEl.createDiv(\"ai-sidebar__message ai-sidebar__message--thinking\");\n thinkingEl.createDiv({ cls: \"ai-sidebar__message-role\", text: \"assistant\" });\n const row = thinkingEl.createDiv(\"ai-sidebar__typing\");\n row.createSpan({ text: \"Thinking\" });\n const dots = row.createSpan(\"ai-sidebar__typing-dots\");\n dots.createSpan();\n dots.createSpan();\n dots.createSpan();\n }\n\n this.threadEl.scrollTo({ top: this.threadEl.scrollHeight });\n }\n\n private async refreshContextPreview(prompt = \"\") {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n prompt,\n this.extractSelectedSkillNames(prompt),\n );\n this.activeContextPath = context.activeFile?.path ?? \"\";\n this.contextEl.empty();\n this.contextEl.createDiv({\n cls: \"ai-sidebar__context-title\",\n text: `Context preview · ${this.accessModeLabel()}`,\n });\n\n const items = [\n context.activeFile ? `Active: ${context.activeFile.path}` : \"No active note\",\n context.selection ? \"Selection included\" : \"No selection\",\n `${context.linkedFiles.length} linked`,\n `${context.backlinkFiles.length} backlinks`,\n `${context.relevantFiles.length} relevant`,\n `${context.baseFiles.length} bases`,\n `${context.selectedSkills.length} skills`,\n ];\n\n const list = this.contextEl.createEl(\"ul\");\n for (const item of items) {\n list.createEl(\"li\", { text: item });\n }\n }\n\n private scheduleContextRefresh() {\n window.clearTimeout(this.refreshTimer);\n this.refreshTimer = window.setTimeout(() => {\n void this.refreshContextPreview(this.textareaEl?.value ?? \"\");\n }, 150);\n }\n\n private accessModeLabel(): string {\n if (this.accessMode === \"read-only\") return \"read only\";\n if (this.accessMode === \"full-access\") return \"full access\";\n return \"confirm actions\";\n }\n\n private async sendPrompt() {\n const rawPrompt = this.textareaEl.value.trim();\n if (!rawPrompt || this.isRunning) return;\n\n const provider = this.plugin.getProvider(this.providerId);\n if (!provider) {\n new Notice(\"Choose a provider in AI sidebar settings.\");\n return;\n }\n\n if (!isProviderReady(provider)) {\n new Notice(\"Connect or configure this provider in AI sidebar settings first.\");\n return;\n }\n\n const parsed = this.parsePromptControls(rawPrompt, provider);\n if (this.isSlashOnlyPrompt(rawPrompt, parsed)) {\n await this.applySlashControls(provider, parsed);\n this.textareaEl.value = \"\";\n this.updateModelBadge();\n await this.refreshContextPreview();\n return;\n }\n\n this.isRunning = true;\n this.sendButton.setButtonText(\"Running...\");\n this.sendButton.setDisabled(true);\n this.textareaEl.value = \"\";\n const effectiveAccessMode = parsed.accessMode ?? this.accessMode;\n this.messages.push({ role: \"user\", content: rawPrompt });\n this.renderMessages();\n\n try {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n parsed.cleanPrompt,\n parsed.selectedSkillNames,\n );\n await this.refreshContextPreview(rawPrompt);\n const request: AgentRequest = {\n prompt: parsed.cleanPrompt,\n accessMode: effectiveAccessMode,\n context,\n instructions: buildAgentInstructions(effectiveAccessMode),\n conversation: this.plugin.settings.enableConversationMemory ? this.messages.slice(0, -1) : [],\n options: parsed.options,\n };\n\n const response = await runProvider(provider, request, this.app);\n const actionResult = await this.handleActions(response);\n const assistantContent = actionResult ? `${response}\\n\\n${actionResult}` : response;\n this.messages.push({ role: \"assistant\", content: assistantContent.trim() || \"No response.\" });\n await this.plugin.rememberMessages(this.messages);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n this.messages.push({ role: \"system\", content: `Provider failed: ${message}` });\n await this.plugin.rememberMessages(this.messages);\n new Notice(\"AI provider failed. Check the sidebar for details.\");\n } finally {\n this.isRunning = false;\n this.sendButton.setButtonText(\"Send\");\n this.sendButton.setDisabled(false);\n this.updateModelBadge();\n this.renderMessages();\n }\n }\n\n private async clearConversation() {\n this.messages = [];\n await this.plugin.clearMemory();\n this.renderMessages();\n new Notice(\"AI sidebar memory cleared.\");\n }\n\n private async handleActions(response: string): Promise<string> {\n const actions = extractVaultActions(response);\n if (actions.length === 0) return \"\";\n\n if (this.accessMode === \"read-only\") {\n return \"Vault actions were proposed, but read-only mode blocked all writes.\";\n }\n\n if (this.accessMode === \"confirm\") {\n const approved = await confirmVaultActions(this.app, actions);\n if (!approved) return \"Vault actions were not applied.\";\n }\n\n const results: string[] = [];\n for (const action of actions) {\n try {\n await applyVaultAction(this.app, action);\n results.push(`Applied ${action.type}: ${action.path}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n results.push(`Failed ${action.type}: ${action.path} · ${message}`);\n }\n }\n return results.join(\"\\n\");\n }\n\n private async updateSlashCommands() {\n const query = this.currentSlashQuery();\n if (query === null) {\n this.hideSlashCommands();\n return;\n }\n\n const provider = this.plugin.getProvider(this.providerId);\n const commands = provider ? nativeSlashCommands(provider, query, this.accessMode) : [];\n const skills = await listAgentSkills(this.plugin.app, query);\n this.slashEl.empty();\n\n if (commands.length === 0 && skills.length === 0) {\n this.hideSlashCommands();\n return;\n }\n\n this.slashEl.removeClass(\"is-hidden\");\n if (skills.length > 0) {\n this.slashEl.createDiv({ cls: \"ai-sidebar__slash-section\", text: \"Skills\" });\n }\n for (const skill of skills.slice(0, 12)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: `/${skill.name}` });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: skill.path });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(`/${skill.name}`);\n });\n }\n if (commands.length > 0) {\n this.slashEl.createDiv({ cls: \"ai-sidebar__slash-section\", text: \"Controls\" });\n }\n for (const command of commands.slice(0, 8)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: command.label });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: command.description });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(command.insert);\n });\n }\n }\n\n private hideSlashCommands() {\n this.slashEl.empty();\n this.slashEl.addClass(\"is-hidden\");\n }\n\n private currentSlashQuery(): string | null {\n const cursor = this.textareaEl.selectionStart;\n const beforeCursor = this.textareaEl.value.slice(0, cursor);\n const match = beforeCursor.match(/(?:^|\\s)\\/([A-Za-z0-9_.:-]*)$/);\n return match ? match[1].toLowerCase() : null;\n }\n\n private insertSlashToken(token: string) {\n const cursor = this.textareaEl.selectionStart;\n const value = this.textareaEl.value;\n const beforeCursor = value.slice(0, cursor);\n const afterCursor = value.slice(cursor);\n const replaced = beforeCursor.replace(/(?:^|\\s)\\/([A-Za-z0-9_.:-]*)$/, (match) => {\n const prefix = match.startsWith(\" \") ? \" \" : \"\";\n return `${prefix}${token} `;\n });\n this.textareaEl.value = `${replaced}${afterCursor}`;\n this.textareaEl.focus();\n this.textareaEl.selectionStart = replaced.length;\n this.textareaEl.selectionEnd = replaced.length;\n this.hideSlashCommands();\n this.scheduleContextRefresh();\n }\n\n private extractSelectedSkillNames(prompt: string): string[] {\n const native = new Set([\"model\", \"reasoning\", \"access\", \"memory\"]);\n return Array.from(prompt.matchAll(/(?:^|\\s)\\/([A-Za-z0-9_.-]+)/g), (match) => match[1])\n .filter((name) => !native.has(name.split(\":\")[0].toLowerCase()));\n }\n\n private parsePromptControls(prompt: string, provider: AgentProvider): ParsedPrompt {\n const options: AgentRequestOptions = {\n model: provider.model,\n reasoningEffort: provider.reasoningEffort,\n memoryEnabled: this.plugin.settings.enableConversationMemory,\n };\n let accessMode: AccessMode | undefined;\n\n const cleanPrompt = prompt.replace(/(?:^|\\s)\\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match: string, key: string, value: string) => {\n const normalizedKey = String(key).toLowerCase();\n const normalizedValue = String(value).toLowerCase();\n if (normalizedKey === \"model\") {\n options.model = value;\n } else if (normalizedKey === \"reasoning\" && isReasoningEffort(normalizedValue)) {\n options.reasoningEffort = normalizedValue;\n } else if (normalizedKey === \"access\" && isAccessMode(normalizedValue)) {\n accessMode = normalizedValue;\n } else if (normalizedKey === \"memory\") {\n options.memoryEnabled = normalizedValue === \"on\";\n }\n return match.startsWith(\" \") ? \" \" : \"\";\n }).trim();\n\n return {\n cleanPrompt,\n selectedSkillNames: this.extractSelectedSkillNames(prompt),\n options,\n accessMode,\n };\n }\n\n private isSlashOnlyPrompt(prompt: string, parsed: ParsedPrompt): boolean {\n return parsed.cleanPrompt.length === 0 && this.extractSelectedSkillNames(prompt).length === 0;\n }\n\n private async applySlashControls(provider: AgentProvider, parsed: ParsedPrompt) {\n const changes: string[] = [];\n if (parsed.options.model && parsed.options.model !== provider.model) {\n provider.model = parsed.options.model;\n changes.push(`model ${parsed.options.model}`);\n }\n if (parsed.options.reasoningEffort && parsed.options.reasoningEffort !== provider.reasoningEffort) {\n provider.reasoningEffort = parsed.options.reasoningEffort;\n changes.push(`reasoning ${parsed.options.reasoningEffort}`);\n }\n if (parsed.accessMode && parsed.accessMode !== this.accessMode) {\n this.accessMode = parsed.accessMode;\n this.plugin.settings.defaultAccessMode = parsed.accessMode;\n changes.push(`access ${this.accessModeLabel()}`);\n }\n if (parsed.options.memoryEnabled !== this.plugin.settings.enableConversationMemory) {\n this.plugin.settings.enableConversationMemory = parsed.options.memoryEnabled;\n changes.push(`memory ${parsed.options.memoryEnabled ? \"on\" : \"off\"}`);\n }\n await this.plugin.saveSettings();\n new Notice(changes.length > 0 ? `Updated ${changes.join(\", \")}.` : \"Slash command applied.\");\n }\n\n private updateModelBadge() {\n if (!this.modelBadgeEl) return;\n const provider = this.plugin.getProvider(this.providerId);\n const override = this.textareaEl?.value.match(/(?:^|\\s)\\/model:([A-Za-z0-9_.:/-]+)/i)?.[1];\n const model = override || provider?.model || parseProviderModels(provider ?? DEFAULT_SETTINGS.providers[0]).first() || \"default\";\n this.modelBadgeEl.setText(model);\n this.modelBadgeEl.toggleClass(\"is-override\", Boolean(override));\n this.modelBadgeEl.setAttr(\"aria-label\", override ? `Model override: ${model}` : `Model: ${model}`);\n }\n}\n\nclass AISidebarSettingTab extends PluginSettingTab {\n plugin: AISidebarPlugin;\n\n constructor(app: App, plugin: AISidebarPlugin) {\n super(app, plugin);\n this.plugin = plugin;\n }\n\n display(): void {\n const { containerEl } = this;\n containerEl.empty();\n\n new Setting(containerEl)\n .setName(\"Default provider\")\n .setDesc(\"The AI provider used when the sidebar opens.\")\n .addDropdown((dropdown) => {\n dropdown.addOptions(this.providerOptions());\n dropdown.setValue(this.plugin.settings.defaultProviderId);\n dropdown.onChange(async (value) => {\n this.plugin.settings.defaultProviderId = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Default vault access\")\n .setDesc(\"How much permission the agent has when it proposes changes.\")\n .addDropdown((dropdown) => {\n dropdown\n .addOption(\"read-only\", \"Read only\")\n .addOption(\"confirm\", \"Confirm actions\")\n .addOption(\"full-access\", \"Full access\")\n .setValue(this.plugin.settings.defaultAccessMode)\n .onChange(async (value: AccessMode) => {\n this.plugin.settings.defaultAccessMode = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Sidebar shortcut\")\n .setDesc(\"Set a shortcut from Obsidian's hotkeys settings by searching for toggle sidebar.\");\n\n new Setting(containerEl)\n .setName(\"Include folders\")\n .setDesc(\"Comma-separated folder prefixes to include. Leave blank for the whole vault.\")\n .addText((text) => {\n text\n .setPlaceholder(\"Projects, daily notes\")\n .setValue(this.plugin.settings.includeFolders)\n .onChange(async (value) => {\n this.plugin.settings.includeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Exclude folders\")\n .setDesc(\"Comma-separated folder prefixes that should never be sent to an agent.\")\n .addText((text) => {\n text\n .setValue(this.plugin.settings.excludeFolders)\n .onChange(async (value) => {\n this.plugin.settings.excludeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Max context characters\")\n .setDesc(\"Upper bound for note text sent to the agent.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxContextChars))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed > 1000) {\n this.plugin.settings.maxContextChars = parsed;\n await this.plugin.saveSettings();\n }\n });\n });\n\n new Setting(containerEl)\n .setName(\"Conversation memory\")\n .setDesc(\"Remember previous sidebar messages after closing and reopening Obsidian.\")\n .addToggle((toggle) => {\n toggle\n .setValue(this.plugin.settings.enableConversationMemory)\n .onChange(async (value) => {\n this.plugin.settings.enableConversationMemory = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Memory length\")\n .setDesc(\"Maximum number of recent sidebar messages to remember.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxMemoryMessages))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed >= 2) {\n this.plugin.settings.maxMemoryMessages = parsed;\n await this.plugin.saveSettings();\n }\n });\n })\n .addButton((button) => {\n button.setButtonText(\"Clear memory\").onClick(async () => {\n await this.plugin.clearMemory();\n new Notice(\"AI sidebar memory cleared.\");\n });\n });\n\n new Setting(containerEl)\n .setName(\"Connect a provider\")\n .setHeading();\n this.renderProviderPresets(containerEl);\n\n new Setting(containerEl)\n .setName(\"Connected providers\")\n .setHeading();\n for (const provider of this.plugin.settings.providers) {\n this.renderProviderSetting(containerEl, provider);\n }\n\n new Setting(containerEl)\n .setName(\"Advanced provider\")\n .setDesc(\"Create a custom API, OAUTH, or local CLI provider.\")\n .addButton((button) => {\n button.setButtonText(\"Add custom\").onClick(async () => {\n const id = `provider-${Date.now()}`;\n this.plugin.settings.providers.push({ id, name: \"New provider\", authType: \"oauth\", command: \"\" });\n await this.plugin.saveSettings();\n this.display();\n });\n });\n }\n\n private renderProviderPresets(containerEl: HTMLElement) {\n const grid = containerEl.createDiv(\"ai-sidebar-provider-grid\");\n for (const preset of PROVIDER_PRESETS) {\n const provider = this.plugin.getProvider(preset.provider.id);\n const card = grid.createDiv(\"ai-sidebar-provider-card\");\n card.createDiv({ cls: \"ai-sidebar-provider-card__name\", text: preset.name });\n card.createDiv({ cls: \"ai-sidebar-provider-card__desc\", text: preset.description });\n const status = provider ? providerConnectionLabel(provider) : \"Not added\";\n card.createDiv({ cls: \"ai-sidebar-provider-card__status\", text: status });\n\n const actions = card.createDiv(\"ai-sidebar-provider-card__actions\");\n new ButtonComponent(actions)\n .setButtonText(provider ? \"Use\" : \"Add\")\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n this.display();\n });\n\n new ButtonComponent(actions)\n .setCta()\n .setButtonText(providerConnectionActionText(provider ?? preset.provider))\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n const connectedProvider = this.plugin.getProvider(preset.provider.id) ?? preset.provider;\n if (await startProviderSignIn(connectedProvider)) {\n connectedProvider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n }\n this.display();\n });\n }\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderProviderSetting(containerEl: HTMLElement, provider: AgentProvider) {\n const wrapper = containerEl.createDiv(\"ai-sidebar-settings-provider\");\n const header = wrapper.createDiv(\"ai-sidebar-settings-provider__header\");\n const title = header.createDiv(\"ai-sidebar-settings-provider__title\");\n title.createDiv({ cls: \"ai-sidebar-settings-provider__name\", text: provider.name || provider.id });\n title.createDiv({ cls: \"ai-sidebar-settings-provider__desc\", text: this.providerDescription(provider) });\n header.createSpan({\n cls: `ai-sidebar-settings-provider__badge ${isProviderConnected(provider) ? \"is-connected\" : \"\"}`,\n text: providerConnectionLabel(provider),\n });\n\n const actions = wrapper.createDiv(\"ai-sidebar-settings-provider__actions\");\n new ButtonComponent(actions)\n .setButtonText(this.plugin.settings.defaultProviderId === provider.id ? \"Default\" : \"Use\")\n .setDisabled(this.plugin.settings.defaultProviderId === provider.id)\n .onClick(async () => {\n this.plugin.settings.defaultProviderId = provider.id;\n await this.plugin.saveSettings();\n this.display();\n });\n this.configureConnectButton(new ButtonComponent(actions), provider);\n new ButtonComponent(actions)\n .setIcon(\"trash\")\n .setTooltip(\"Remove provider\")\n .onClick(async () => {\n this.plugin.settings.providers = this.plugin.settings.providers.filter((item) => item !== provider);\n if (this.plugin.settings.defaultProviderId === provider.id) {\n this.plugin.settings.defaultProviderId = this.plugin.settings.providers.first()?.id ?? \"\";\n }\n await this.plugin.saveSettings();\n this.display();\n });\n\n const fields = wrapper.createDiv(\"ai-sidebar-settings-provider__fields\");\n this.renderTextField(fields, \"Name\", provider.name, async (value) => {\n provider.name = value;\n await this.plugin.saveSettings();\n this.display();\n });\n\n if (provider.authType === \"cli\") {\n this.renderTextField(fields, \"Command\", provider.command, async (value) => {\n provider.command = value;\n await this.plugin.saveSettings();\n });\n if (provider.signInCommand) {\n this.renderTextField(fields, \"Sign-in command\", provider.signInCommand, async (value) => {\n provider.signInCommand = value;\n provider.connectedAt = undefined;\n await this.plugin.saveSettings();\n });\n }\n }\n\n if (provider.authType === \"api-key\") {\n this.renderTextField(fields, \"API key\", provider.apiKey ?? \"\", async (value) => {\n provider.apiKey = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n this.renderTextField(fields, \"Endpoint\", provider.apiBaseUrl ?? \"\", async (value) => {\n provider.apiBaseUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Default model\", provider.model ?? \"\", async (value) => {\n provider.model = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Slash models\", provider.models ?? \"\", async (value) => {\n provider.models = value;\n await this.plugin.saveSettings();\n });\n }\n\n if (provider.authType === \"oauth\") {\n this.renderTextField(fields, \"Authorization URL\", provider.authorizationUrl ?? \"\", async (value) => {\n provider.authorizationUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Client ID\", provider.clientId ?? \"\", async (value) => {\n provider.clientId = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Access token\", provider.accessToken ?? \"\", async (value) => {\n provider.accessToken = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n }\n }\n\n private renderTextField(\n containerEl: HTMLElement,\n label: string,\n value: string,\n onChange: (value: string) => Promise<void>,\n secret = false,\n ) {\n const field = containerEl.createDiv(\"ai-sidebar-settings-field\");\n field.createDiv({ cls: \"ai-sidebar-settings-field__label\", text: label });\n const input = field.createEl(\"input\", {\n cls: \"ai-sidebar-settings-field__input\",\n attr: { type: secret ? \"password\" : \"text\" },\n });\n input.value = value;\n input.addEventListener(\"change\", () => {\n void onChange(input.value);\n });\n }\n\n private providerDescription(provider: AgentProvider): string {\n if (provider.authType === \"api-key\") {\n return provider.apiKey ? \"Connected with API key.\" : \"Add an API key to connect.\";\n }\n if (provider.authType === \"oauth\") {\n return provider.accessToken ? \"Connected with OAuth.\" : \"Connect with OAuth when the provider exposes a desktop OAuth app.\";\n }\n return provider.command ? `Runs local command: ${provider.command}` : \"Add the local CLI command.\";\n }\n\n private configureConnectButton(button: ButtonComponent, provider: AgentProvider) {\n button\n .setButtonText(providerConnectionActionText(provider))\n .onClick(async () => {\n if (await startProviderSignIn(provider)) {\n provider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n this.display();\n }\n });\n }\n}\n\nclass ConfirmActionsModal extends Modal {\n private actions: VaultAction[];\n private resolve: (approved: boolean) => void;\n\n constructor(app: App, actions: VaultAction[], resolve: (approved: boolean) => void) {\n super(app);\n this.actions = actions;\n this.resolve = resolve;\n }\n\n onOpen() {\n const { contentEl } = this;\n contentEl.empty();\n new Setting(contentEl)\n .setName(\"Apply AI vault actions?\")\n .setHeading();\n contentEl.createEl(\"p\", { text: \"Review these proposed file changes before they are applied.\" });\n\n for (const action of this.actions) {\n const actionEl = contentEl.createDiv(\"ai-sidebar-action-preview\");\n actionEl.createEl(\"strong\", { text: `${action.type}: ${action.path}` });\n if (action.type === \"rename\") {\n actionEl.createDiv({ text: `New path: ${action.newPath}` });\n }\n if (\"content\" in action) {\n actionEl.createEl(\"pre\", { text: action.content.slice(0, 1200) });\n }\n }\n\n new Setting(contentEl)\n .addButton((button) => {\n button.setButtonText(\"Cancel\").onClick(() => {\n this.resolve(false);\n this.close();\n });\n })\n .addButton((button) => {\n button.setCta().setButtonText(\"Apply\").onClick(() => {\n this.resolve(true);\n this.close();\n });\n });\n }\n\n onClose() {\n this.contentEl.empty();\n }\n}\n\nasync function collectVaultContext(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n selectedSkillNames: string[] = [],\n): Promise<VaultContext> {\n const activeView = app.workspace.getActiveViewOfType(MarkdownView);\n const activeFile = activeView?.file ?? app.workspace.getActiveFile();\n const selection = activeView?.editor.getSelection() || undefined;\n const budget = new ContextBudget(settings.maxContextChars);\n\n const context: VaultContext = {\n selection,\n linkedFiles: [],\n backlinkFiles: [],\n relevantFiles: [],\n baseFiles: [],\n selectedSkills: [],\n };\n\n const activeContext = activeFile ? await readContextFile(app, activeFile, \"active note\", budget) : undefined;\n if (activeContext) context.activeFile = activeContext;\n\n const linked = activeFile ? getLinkedFiles(app, activeFile) : [];\n for (const file of linked) {\n const item = await readContextFile(app, file, \"linked note\", budget);\n if (item) context.linkedFiles.push(item);\n }\n\n const backlinks = activeFile ? getBacklinkFiles(app, activeFile) : [];\n for (const file of backlinks) {\n const item = await readContextFile(app, file, \"backlink\", budget);\n if (item) context.backlinkFiles.push(item);\n }\n\n const baseFiles = app.vault\n .getFiles()\n .filter((file) => file.extension === \"base\" && shouldIncludeFile(app, file, settings))\n .slice(0, 5);\n for (const file of baseFiles) {\n const item = await readContextFile(app, file, \"base file\", budget);\n if (item) context.baseFiles.push(item);\n }\n\n const relatedFiles = await findRelevantFiles(app, settings, prompt, activeFile, budget.remaining());\n for (const file of relatedFiles) {\n const item = await readContextFile(app, file, \"relevant match\", budget);\n if (item) context.relevantFiles.push(item);\n }\n\n context.selectedSkills = await readSelectedSkills(app, selectedSkillNames, budget);\n\n return context;\n}\n\nasync function readContextFile(\n app: App,\n file: TFile,\n reason: string,\n budget: ContextBudget,\n): Promise<VaultContextFile | undefined> {\n if (!budget.hasRoom()) return undefined;\n const content = await app.vault.cachedRead(file);\n const clipped = budget.take(content);\n return { path: file.path, content: clipped, reason };\n}\n\nfunction getLinkedFiles(app: App, activeFile: TFile): TFile[] {\n const cache = app.metadataCache.getFileCache(activeFile);\n const links = cache?.links ?? [];\n const files: TFile[] = [];\n\n for (const link of links) {\n const linkedFile = app.metadataCache.getFirstLinkpathDest(link.link, activeFile.path);\n if (linkedFile) files.push(linkedFile);\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nfunction getBacklinkFiles(app: App, activeFile: TFile): TFile[] {\n const resolvedLinks = app.metadataCache.resolvedLinks;\n const files: TFile[] = [];\n\n for (const [sourcePath, targets] of Object.entries(resolvedLinks)) {\n if (targets[activeFile.path]) {\n const file = app.vault.getAbstractFileByPath(sourcePath);\n if (file instanceof TFile) files.push(file);\n }\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nasync function findRelevantFiles(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n activeFile: TFile | null,\n maxChars: number,\n): Promise<TFile[]> {\n if (!prompt.trim() || maxChars <= 0) return [];\n const terms = prompt\n .toLowerCase()\n .split(/[^a-z0-9/_-]+/i)\n .filter((term) => term.length > 2)\n .slice(0, 16);\n if (terms.length === 0) return [];\n\n const scored: Array<{ file: TFile; score: number }> = [];\n const files = app.vault\n .getFiles()\n .filter((file) => file !== activeFile && shouldIncludeFile(app, file, settings) && isReadableContextFile(file));\n\n for (const file of files) {\n const content = (await app.vault.cachedRead(file)).toLowerCase();\n const path = file.path.toLowerCase();\n let score = 0;\n for (const term of terms) {\n if (path.includes(term)) score += 4;\n const matches = content.match(new RegExp(escapeRegExp(term), \"g\"));\n score += matches ? Math.min(matches.length, 8) : 0;\n }\n if (score > 0) scored.push({ file, score });\n }\n\n return scored\n .sort((a, b) => b.score - a.score)\n .slice(0, 10)\n .map((item) => item.file);\n}\n\nfunction shouldIncludeFile(app: App, file: TFile, settings: AISidebarSettings): boolean {\n const includes = parseFolderList(settings.includeFolders);\n const excludes = [app.vault.configDir, ...parseFolderList(settings.excludeFolders)];\n if (includes.length > 0 && !includes.some((folder) => file.path.startsWith(folder))) return false;\n if (excludes.some((folder) => file.path.startsWith(folder))) return false;\n return isReadableContextFile(file);\n}\n\nfunction isReadableContextFile(file: TFile): boolean {\n return [\"md\", \"canvas\", \"base\", \"json\", \"txt\"].includes(file.extension);\n}\n\nfunction nativeSlashCommands(\n provider: AgentProvider,\n query: string,\n currentAccessMode: AccessMode,\n): Array<{ label: string; description: string; insert: string }> {\n const commands: Array<{ label: string; description: string; insert: string }> = [];\n const modelChoices = parseProviderModels(provider);\n\n for (const model of modelChoices) {\n commands.push({\n label: `/model:${model}`,\n description: `Use ${model} for this request`,\n insert: `/model:${model}`,\n });\n }\n\n if (provider.authType === \"api-key\" || provider.id.includes(\"codex\") || provider.id.includes(\"openai\")) {\n for (const effort of [\"low\", \"medium\", \"high\"] as ReasoningEffort[]) {\n commands.push({\n label: `/reasoning:${effort}`,\n description: `Set reasoning effort to ${effort}`,\n insert: `/reasoning:${effort}`,\n });\n }\n }\n\n for (const mode of [\"read-only\", \"confirm\", \"full-access\"] as AccessMode[]) {\n commands.push({\n label: `/access:${mode}`,\n description: mode === currentAccessMode ? \"Current vault access mode\" : `Use ${mode} for this request`,\n insert: `/access:${mode}`,\n });\n }\n\n commands.push(\n { label: \"/memory:on\", description: \"Include remembered chat history for this request\", insert: \"/memory:on\" },\n { label: \"/memory:off\", description: \"Do not include remembered chat history for this request\", insert: \"/memory:off\" },\n );\n\n const normalizedQuery = query.toLowerCase();\n return commands.filter((command) => command.label.slice(1).toLowerCase().includes(normalizedQuery));\n}\n\nfunction parseProviderModels(provider: AgentProvider): string[] {\n const configured = provider.models\n ?.split(\",\")\n .map((model) => model.trim())\n .filter(Boolean) ?? [];\n const models = configured.length > 0 ? configured : [provider.model].filter((model): model is string => Boolean(model));\n return Array.from(new Set(models)).slice(0, 12);\n}\n\nasync function listAgentSkills(app: App, query = \"\"): Promise<SkillReference[]> {\n const normalizedQuery = query.toLowerCase();\n const vaultSkills = app.vault\n .getFiles()\n .filter((file) => isSkillFile(file))\n .map((file) => ({\n name: skillNameFromPath(file.path),\n path: file.path,\n source: \"vault\" as const,\n }));\n const adapterSkills = await listAgentSkillsFromAdapter(app);\n const globalSkills = await listGlobalAgentSkills();\n const skills = [...vaultSkills, ...adapterSkills, ...globalSkills]\n .filter((skill) => !normalizedQuery || skill.name.toLowerCase().includes(normalizedQuery))\n .sort((a, b) => a.name.localeCompare(b.name));\n\n const seen = new Set<string>();\n return skills.filter((skill) => {\n const key = `${skill.source}:${skill.name.toLowerCase()}`;\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\nasync function listAgentSkillsFromAdapter(app: App): Promise<SkillReference[]> {\n const roots = [\".agent/skills\", \".agents/skills\", \"agent/skills\", \"agents/skills\"];\n const files: string[] = [];\n for (const root of roots) {\n files.push(...await listAdapterFiles(app, root));\n }\n return files\n .filter((path) => isSkillPath(path))\n .map((path) => ({\n name: skillNameFromPath(path),\n path,\n source: \"vault\" as const,\n }));\n}\n\nasync function listGlobalAgentSkills(): Promise<SkillReference[]> {\n const roots = [\n path.join(homedir(), \".agent\", \"skills\", \"skills\"),\n path.join(homedir(), \".agents\", \"skills\", \"skills\"),\n ];\n const files: string[] = [];\n for (const root of roots) {\n files.push(...await listFilesystemFiles(root));\n }\n return files\n .filter((filePath) => isGlobalSkillPath(filePath))\n .map((filePath) => ({\n name: skillNameFromPath(filePath),\n path: filePath,\n source: \"global\" as const,\n }));\n}\n\nasync function listFilesystemFiles(folder: string): Promise<string[]> {\n try {\n const entries = await fs.readdir(folder, { withFileTypes: true });\n const results = await Promise.all(entries.map(async (entry) => {\n const childPath = path.join(folder, entry.name);\n if (entry.isDirectory()) return listFilesystemFiles(childPath);\n if (entry.isFile()) return [childPath];\n return [];\n }));\n return results.flat();\n } catch {\n return [];\n }\n}\n\nasync function listAdapterFiles(app: App, folder: string): Promise<string[]> {\n try {\n const listed = await app.vault.adapter.list(folder);\n const childFiles = listed.files;\n const nested = await Promise.all(listed.folders.map((child) => listAdapterFiles(app, child)));\n return [...childFiles, ...nested.flat()];\n } catch {\n return [];\n }\n}\n\nasync function readSelectedSkills(\n app: App,\n selectedSkillNames: string[],\n budget: ContextBudget,\n): Promise<AgentSkill[]> {\n const names = new Set(selectedSkillNames.map((name) => name.toLowerCase()));\n if (names.size === 0) return [];\n\n const skills: AgentSkill[] = [];\n const skillFiles = [...await listAgentSkillsFromAdapter(app), ...await listGlobalAgentSkills()];\n const vaultFiles = app.vault\n .getFiles()\n .filter((candidate) => isSkillFile(candidate))\n .map((file) => ({ name: skillNameFromPath(file.path), path: file.path, source: \"vault\" as const }));\n for (const skillFile of [...vaultFiles, ...skillFiles]) {\n const name = skillFile.name;\n if (!names.has(name.toLowerCase()) || !budget.hasRoom()) continue;\n const content = await readSkillContent(app, skillFile);\n skills.push({\n name,\n path: skillFile.path,\n content: budget.take(content),\n });\n }\n return skills;\n}\n\nasync function readSkillContent(app: App, skill: SkillReference): Promise<string> {\n if (skill.source === \"global\") return fs.readFile(skill.path, \"utf8\");\n const file = app.vault.getAbstractFileByPath(skill.path);\n if (file instanceof TFile) return app.vault.cachedRead(file);\n return app.vault.adapter.read(skill.path);\n}\n\nfunction isSkillFile(file: TFile): boolean {\n return isSkillPath(file.path);\n}\n\nfunction isSkillPath(path: string): boolean {\n if (!hasSupportedSkillExtension(path)) return false;\n const normalized = path.replace(/^\\/+/, \"\");\n return normalized.startsWith(\".agent/skills/\")\n || normalized.startsWith(\".agents/skills/\")\n || normalized.startsWith(\"agent/skills/\")\n || normalized.startsWith(\"agents/skills/\");\n}\n\nfunction hasSupportedSkillExtension(path: string): boolean {\n const extension = path.split(\".\").last()?.toLowerCase() ?? \"\";\n return [\"md\", \"txt\", \"json\", \"yaml\", \"yml\", \"prompt\"].includes(extension);\n}\n\nfunction isGlobalSkillPath(filePath: string): boolean {\n return path.basename(filePath).toLowerCase() === \"skill.md\";\n}\n\nfunction skillNameFromPath(path: string): string {\n const parts = path.split(\"/\");\n const fileName = parts.last() ?? path;\n if (fileName.toLowerCase() === \"skill.md\" && parts.length > 1) {\n return parts[parts.length - 2];\n }\n return fileName.replace(/\\.[^.]+$/, \"\");\n}\n\nfunction parseFolderList(value: string): string[] {\n return value\n .split(\",\")\n .map((folder) => folder.trim().replace(/^\\/+/, \"\"))\n .filter(Boolean);\n}\n\nclass ContextBudget {\n private remainingChars: number;\n\n constructor(maxChars: number) {\n this.remainingChars = maxChars;\n }\n\n hasRoom(): boolean {\n return this.remainingChars > 0;\n }\n\n remaining(): number {\n return this.remainingChars;\n }\n\n take(content: string): string {\n const clipped = content.slice(0, this.remainingChars);\n this.remainingChars -= clipped.length;\n return clipped;\n }\n}\n\nfunction buildAgentInstructions(accessMode: AccessMode): string {\n return [\n \"You are running inside an Obsidian AI Sidebar plugin.\",\n \"Use the provided vault context to answer the user.\",\n \"If the prompt contains slash commands such as /writer or /reviewer, apply the matching selectedSkills instructions from the request context.\",\n \"The request may include conversation memory and per-request options such as model or reasoning effort.\",\n \"When you need file changes, include a JSON code block with this shape:\",\n '{\"actions\":[{\"type\":\"edit\",\"path\":\"Note.md\",\"content\":\"new content\"}]}',\n \"Allowed action types: create, edit, append, delete, rename.\",\n `Current access mode: ${accessMode}. The plugin enforces this mode before applying actions.`,\n ].join(\"\\n\");\n}\n\nasync function runProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n if (provider.authType === \"api-key\") {\n return runOpenAICompatibleProvider(provider, request);\n }\n\n if (provider.authType === \"oauth\") {\n if (!provider.accessToken) {\n throw new Error(\"This OAuth provider is not connected yet.\");\n }\n return runOAuthProvider(provider, request);\n }\n\n return runCliProvider(provider, request, app);\n}\n\nasync function runOpenAICompatibleProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiKey) throw new Error(\"Missing API key.\");\n const url = provider.apiBaseUrl || \"https://api.openai.com/v1/responses\";\n const model = request.options.model || provider.model || \"gpt-4.1-mini\";\n const userPayload = JSON.stringify({\n prompt: request.prompt,\n accessMode: request.accessMode,\n context: request.context,\n options: request.options,\n });\n const messages = [\n { role: \"system\", content: request.instructions },\n ...request.conversation.map((message) => ({\n role: message.role === \"assistant\" ? \"assistant\" : \"user\",\n content: message.content,\n })),\n { role: \"user\", content: userPayload },\n ];\n const usesChatCompletions = url.includes(\"/chat/completions\");\n const body: Record<string, unknown> = usesChatCompletions\n ? { model, messages }\n : { model, input: messages };\n if (request.options.reasoningEffort && model.startsWith(\"o\")) {\n body.reasoning = { effort: request.options.reasoningEffort };\n }\n const response = await requestUrl({\n url,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n return extractOpenAIText(response.json);\n}\n\nasync function runOAuthProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiBaseUrl) {\n throw new Error(\"OAuth provider needs an API endpoint after login.\");\n }\n const response = await requestUrl({\n url: provider.apiBaseUrl,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.accessToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(request),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n const responseJson = response.json as { text?: unknown; output_text?: unknown } | undefined;\n if (typeof responseJson?.text === \"string\") return responseJson.text;\n if (typeof responseJson?.output_text === \"string\") return responseJson.output_text;\n return response.text;\n}\n\nfunction runCliProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n return new Promise((resolve, reject) => {\n const prompt = cliPromptFromRequest(request);\n const command = cliCommandForRequest(provider, request, app);\n const shellCommand = `${command} ${shellQuote(prompt)}`;\n const child = spawn(\"/bin/zsh\", [\"-lc\", shellCommand], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n let stdout = \"\";\n let stderr = \"\";\n\n child.stdout.on(\"data\", (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) {\n resolve(stdout.trim());\n } else {\n reject(new Error(stderr.trim() || `Command exited with code ${code}. Check that ${command} is installed and available in your shell.`));\n }\n });\n\n });\n}\n\nfunction cliCommandForRequest(provider: AgentProvider, request: AgentRequest, app: App): string {\n const vaultPath = getVaultPath(app);\n if (provider.id === \"codex\" || provider.command.startsWith(\"codex exec\")) {\n const sandbox = providerSandboxForAccessMode(request.accessMode);\n const cdArg = vaultPath ? ` -C ${shellQuote(vaultPath)}` : \"\";\n return `${provider.command}${cdArg} --skip-git-repo-check -s ${sandbox}`;\n }\n return provider.command;\n}\n\nfunction providerSandboxForAccessMode(accessMode: AccessMode): string {\n if (accessMode === \"full-access\") return \"workspace-write\";\n return \"read-only\";\n}\n\nfunction getVaultPath(app: App): string | undefined {\n const adapter = app.vault.adapter;\n if (\"basePath\" in adapter && typeof adapter.basePath === \"string\") {\n return adapter.basePath;\n }\n return undefined;\n}\n\nfunction cliPromptFromRequest(request: AgentRequest): string {\n return [\n request.instructions,\n \"\",\n \"User request:\",\n request.prompt,\n \"\",\n \"Vault context and options:\",\n JSON.stringify({\n accessMode: request.accessMode,\n options: request.options,\n conversation: request.options.memoryEnabled ? request.conversation : [],\n context: request.context,\n }, null, 2),\n ].join(\"\\n\");\n}\n\nfunction openInteractiveTerminal(command: string, title: string): Promise<void> {\n const escapedCommand = escapeForSingleQuotedShell(command);\n const script = [\n `tell application \"Terminal\"`,\n \"activate\",\n `do script \"printf '\\\\\\\\033]0;${escapeForAppleScript(title)}\\\\\\\\007'; clear; echo 'AI Sidebar sign-in'; echo ''; echo 'Running: ${escapeForAppleScript(command)}'; echo ''; /bin/zsh -lc '${escapedCommand}; echo; echo Sign-in command finished. You can close this window when done.'\"`\n ,\n \"end tell\",\n ].join(\"\\n\");\n\n return new Promise((resolve, reject) => {\n const child = spawn(\"osascript\", [\"-e\", script], {\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n });\n let stderr = \"\";\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) resolve();\n else reject(new Error(stderr.trim() || `Could not open Terminal. Exit code ${code}`));\n });\n });\n}\n\nfunction extractOpenAIText(json: unknown): string {\n if (!json || typeof json !== \"object\") return \"\";\n const response = json as {\n output_text?: string;\n output?: Array<{ content?: Array<{ text?: string; type?: string }> }>;\n choices?: Array<{ message?: { content?: string } }>;\n };\n if (typeof response.output_text === \"string\") return response.output_text;\n if (typeof response.choices?.[0]?.message?.content === \"string\") {\n return response.choices[0].message.content;\n }\n const parts: string[] = [];\n for (const item of response.output ?? []) {\n for (const content of item.content ?? []) {\n if (typeof content.text === \"string\") parts.push(content.text);\n }\n }\n return parts.join(\"\\n\");\n}\n\nfunction startOAuthLogin(provider: AgentProvider): boolean {\n if (!provider.authorizationUrl || !provider.clientId) {\n new Notice(\"Add an authorization URL and client ID first.\");\n return false;\n }\n\n const url = new URL(provider.authorizationUrl);\n url.searchParams.set(\"response_type\", \"token\");\n url.searchParams.set(\"client_id\", provider.clientId);\n url.searchParams.set(\"redirect_uri\", \"urn:ietf:wg:oauth:2.0:oob\");\n if (provider.scope) url.searchParams.set(\"scope\", provider.scope);\n url.searchParams.set(\"state\", crypto.randomUUID());\n\n window.open(url.toString());\n new Notice(\"OAUTH sign-in opened in your browser. Paste the returned token into this provider when available.\");\n return true;\n}\n\nasync function startProviderSignIn(provider: AgentProvider): Promise<boolean> {\n if (provider.authType === \"cli\") {\n if (!provider.signInCommand) {\n new Notice(\"This local provider does not have a sign-in command configured.\");\n return false;\n }\n try {\n await openInteractiveTerminal(provider.signInCommand, `${provider.name} Sign In`);\n new Notice(`Opened Terminal for ${provider.name} sign-in.`);\n return true;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n new Notice(`Could not open sign-in: ${message}`);\n return false;\n }\n }\n\n if (provider.authType === \"api-key\") {\n if (provider.setupUrl) window.open(provider.setupUrl);\n new Notice(provider.apiKey ? `${provider.name} is connected.` : `Add your ${provider.name} API key below to connect.`);\n return Boolean(provider.apiKey);\n }\n\n return startOAuthLogin(provider);\n}\n\nfunction escapeForSingleQuotedShell(value: string): string {\n return value.replace(/'/g, \"'\\\\''\");\n}\n\nfunction shellQuote(value: string): string {\n return `'${escapeForSingleQuotedShell(value)}'`;\n}\n\nfunction escapeForAppleScript(value: string): string {\n return value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\nfunction providerConnectionActionText(provider: AgentProvider): string {\n if (provider.authType === \"cli\") return isProviderConnected(provider) ? \"Reconnect\" : \"Sign in\";\n if (provider.authType === \"api-key\") return provider.apiKey ? \"Connected\" : \"Get key\";\n return provider.accessToken ? \"Reconnect\" : \"Sign in\";\n}\n\nfunction providerConnectionLabel(provider: AgentProvider): string {\n if (isProviderConnected(provider)) return \"Connected\";\n if (provider.authType === \"api-key\") return \"Needs API key\";\n if (provider.authType === \"oauth\") return \"Needs sign-in\";\n return provider.signInCommand ? \"Ready to sign in\" : \"Needs command\";\n}\n\nfunction isProviderConnected(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken);\n return Boolean(provider.connectedAt);\n}\n\nfunction isProviderReady(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey && provider.apiBaseUrl);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken && provider.apiBaseUrl);\n return Boolean(provider.command.trim());\n}\n\nfunction normalizeProviders(providers: AgentProvider[]): AgentProvider[] {\n return providers.map((provider) => {\n const command = provider.id === \"codex\" && provider.command === \"codex\"\n ? \"codex exec\"\n : provider.command ?? \"\";\n return {\n ...provider,\n authType: provider.authType ?? (provider.command ? \"cli\" : \"api-key\"),\n command,\n connectedAt: provider.connectedAt,\n };\n });\n}\n\nfunction cloneProvider(provider: AgentProvider): AgentProvider {\n return { ...provider };\n}\n\nfunction extractVaultActions(response: string): VaultAction[] {\n const actions: VaultAction[] = [];\n const codeBlockPattern = /```(?:json)?\\s*([\\s\\S]*?)```/gi;\n const candidates: string[] = [];\n let match: RegExpExecArray | null;\n\n while ((match = codeBlockPattern.exec(response)) !== null) {\n candidates.push(match[1]);\n }\n candidates.push(response);\n\n for (const candidate of candidates) {\n try {\n const parsed = JSON.parse(candidate.trim()) as { actions?: VaultAction[] };\n if (Array.isArray(parsed.actions)) {\n actions.push(...parsed.actions.filter(isVaultAction));\n }\n } catch {\n continue;\n }\n }\n\n return actions;\n}\n\nfunction isVaultAction(value: unknown): value is VaultAction {\n if (!value || typeof value !== \"object\") return false;\n const action = value as Partial<VaultAction>;\n if (typeof action.type !== \"string\" || typeof action.path !== \"string\") return false;\n if ([\"create\", \"edit\", \"append\"].includes(action.type)) {\n return typeof (action as { content?: unknown }).content === \"string\";\n }\n if (action.type === \"delete\") return true;\n if (action.type === \"rename\") {\n return typeof (action as { newPath?: unknown }).newPath === \"string\";\n }\n return false;\n}\n\nfunction confirmVaultActions(app: App, actions: VaultAction[]): Promise<boolean> {\n return new Promise((resolve) => {\n new ConfirmActionsModal(app, actions, resolve).open();\n });\n}\n\nasync function applyVaultAction(app: App, action: VaultAction): Promise<void> {\n const path = normalizeVaultPath(action.path);\n const file = app.vault.getAbstractFileByPath(path);\n\n if (action.type === \"create\") {\n if (file) throw new Error(\"File already exists.\");\n await ensureParentFolder(app, path);\n await app.vault.create(path, action.content);\n return;\n }\n\n if (!(file instanceof TFile)) {\n throw new Error(\"File does not exist.\");\n }\n\n if (action.type === \"edit\") {\n await app.vault.modify(file, action.content);\n } else if (action.type === \"append\") {\n const current = await app.vault.read(file);\n await app.vault.modify(file, `${current}${current.endsWith(\"\\n\") ? \"\" : \"\\n\"}${action.content}`);\n } else if (action.type === \"delete\") {\n await app.fileManager.trashFile(file);\n } else if (action.type === \"rename\") {\n await ensureParentFolder(app, action.newPath);\n await app.fileManager.renameFile(file, normalizeVaultPath(action.newPath));\n }\n}\n\nasync function ensureParentFolder(app: App, path: string): Promise<void> {\n const parts = normalizeVaultPath(path).split(\"/\");\n parts.pop();\n let current = \"\";\n for (const part of parts) {\n current = current ? `${current}/${part}` : part;\n if (!app.vault.getAbstractFileByPath(current)) {\n await app.vault.createFolder(current);\n }\n }\n}\n\nfunction normalizeVaultPath(path: string): string {\n return path.replace(/^\\/+/, \"\").replace(/\\\\/g, \"/\");\n}\n\nfunction uniqueFiles(files: TFile[]): TFile[] {\n const seen = new Set<string>();\n return files.filter((file) => {\n if (seen.has(file.path)) return false;\n seen.add(file.path);\n return true;\n });\n}\n\nfunction isReasoningEffort(value: string): value is ReasoningEffort {\n return value === \"low\" || value === \"medium\" || value === \"high\";\n}\n\nfunction isAccessMode(value: string): value is AccessMode {\n return value === \"read-only\" || value === \"confirm\" || value === \"full-access\";\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"}, "d4eaf629cf1983257485467e537f921444d74bdf": {"t": 1785759147.5546453, "n": 70032, "h": "d7072acdf89dcc3b", "c": "import {\n App,\n ButtonComponent,\n DropdownComponent,\n type Hotkey,\n ItemView,\n MarkdownView,\n Modal,\n type Modifier,\n Notice,\n Plugin,\n PluginSettingTab,\n requestUrl,\n Setting,\n TFile,\n WorkspaceLeaf,\n} from \"obsidian\";\nimport { spawn } from \"child_process\";\nimport { promises as fs } from \"fs\";\nimport { homedir } from \"os\";\nimport * as path from \"path\";\n\nconst VIEW_TYPE_AI_SIDEBAR = \"ai-sidebar-view\";\n\ntype AccessMode = \"read-only\" | \"confirm\" | \"full-access\";\ntype ProviderAuthType = \"api-key\" | \"oauth\" | \"cli\";\ntype ReasoningEffort = \"low\" | \"medium\" | \"high\";\n\ninterface AgentProvider {\n id: string;\n name: string;\n authType: ProviderAuthType;\n command: string;\n signInCommand?: string;\n setupUrl?: string;\n connectedAt?: number;\n apiBaseUrl?: string;\n apiKey?: string;\n model?: string;\n models?: string;\n reasoningEffort?: ReasoningEffort;\n authorizationUrl?: string;\n tokenUrl?: string;\n clientId?: string;\n scope?: string;\n accessToken?: string;\n refreshToken?: string;\n tokenExpiresAt?: number;\n}\n\ninterface AISidebarSettings {\n providers: AgentProvider[];\n defaultProviderId: string;\n defaultAccessMode: AccessMode;\n sidebarHotkey: string;\n includeFolders: string;\n excludeFolders: string;\n maxContextChars: number;\n enableConversationMemory: boolean;\n maxMemoryMessages: number;\n rememberedMessages: ChatMessage[];\n}\n\ninterface VaultContextFile {\n path: string;\n content: string;\n reason: string;\n}\n\ninterface AgentSkill {\n name: string;\n path: string;\n content: string;\n}\n\ninterface SkillReference {\n name: string;\n path: string;\n source: \"vault\" | \"global\";\n}\n\ninterface VaultContext {\n activeFile?: VaultContextFile;\n selection?: string;\n linkedFiles: VaultContextFile[];\n backlinkFiles: VaultContextFile[];\n relevantFiles: VaultContextFile[];\n baseFiles: VaultContextFile[];\n selectedSkills: AgentSkill[];\n}\n\ninterface ChatMessage {\n role: \"user\" | \"assistant\" | \"system\";\n content: string;\n}\n\ninterface AgentRequest {\n prompt: string;\n accessMode: AccessMode;\n context: VaultContext;\n instructions: string;\n conversation: ChatMessage[];\n options: AgentRequestOptions;\n}\n\ninterface AgentRequestOptions {\n model?: string;\n reasoningEffort?: ReasoningEffort;\n memoryEnabled: boolean;\n}\n\ninterface ParsedPrompt {\n cleanPrompt: string;\n selectedSkillNames: string[];\n options: AgentRequestOptions;\n accessMode?: AccessMode;\n}\n\ntype VaultAction =\n | { type: \"create\"; path: string; content: string }\n | { type: \"edit\"; path: string; content: string }\n | { type: \"append\"; path: string; content: string }\n | { type: \"delete\"; path: string }\n | { type: \"rename\"; path: string; newPath: string };\n\ninterface ProviderPreset {\n id: string;\n name: string;\n description: string;\n provider: AgentProvider;\n}\n\nconst PROVIDER_PRESETS: ProviderPreset[] = [\n {\n id: \"codex\",\n name: \"Codex\",\n description: \"Sign in with Codex locally and use the Codex CLI agent.\",\n provider: {\n id: \"codex\",\n name: \"Codex\",\n authType: \"cli\",\n command: \"codex exec\",\n signInCommand: \"codex login\",\n models: \"gpt-5.4, gpt-5.4-mini, gpt-5.3-codex\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"claude\",\n name: \"Claude Code\",\n description: \"Sign in with your Claude subscription or Anthropic Console account.\",\n provider: {\n id: \"claude\",\n name: \"Claude Code\",\n authType: \"cli\",\n command: \"claude\",\n signInCommand: \"claude auth login\",\n setupUrl: \"https://code.claude.com/docs/en/cli-usage\",\n models: \"opus, sonnet, haiku\",\n },\n },\n {\n id: \"opencode\",\n name: \"opencode\",\n description: \"Connect opencode to any provider it supports, including subscription/API accounts.\",\n provider: {\n id: \"opencode\",\n name: \"opencode\",\n authType: \"cli\",\n command: \"opencode\",\n signInCommand: \"opencode auth login\",\n setupUrl: \"https://opencode.ai/docs/cli/\",\n models: \"anthropic/claude-sonnet-4-5, openai/gpt-5.4, google/gemini-2.5-pro\",\n },\n },\n {\n id: \"openai\",\n name: \"OpenAI API\",\n description: \"Use an OpenAI API key with the Responses API.\",\n provider: {\n id: \"openai\",\n name: \"OpenAI\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.openai.com/api-keys\",\n apiBaseUrl: \"https://api.openai.com/v1/responses\",\n model: \"gpt-4.1-mini\",\n models: \"gpt-4.1-mini, gpt-4.1, o4-mini\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n description: \"Use an Anthropic API key directly through an OpenAI-compatible proxy endpoint.\",\n provider: {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.anthropic.com/settings/keys\",\n apiBaseUrl: \"\",\n model: \"claude-sonnet-4-5\",\n models: \"claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5\",\n },\n },\n {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n description: \"Use Google Gemini CLI with its native sign-in and subscription/API setup.\",\n provider: {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n authType: \"cli\",\n command: \"gemini\",\n signInCommand: \"gemini\",\n setupUrl: \"https://google-gemini.github.io/gemini-cli/docs/\",\n models: \"gemini-2.5-pro, gemini-2.5-flash\",\n },\n },\n {\n id: \"openrouter\",\n name: \"OpenRouter\",\n description: \"Use one API key for many model providers through an OpenAI-compatible endpoint.\",\n provider: {\n id: \"openrouter\",\n name: \"OpenRouter\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://openrouter.ai/settings/keys\",\n apiBaseUrl: \"https://openrouter.ai/api/v1/chat/completions\",\n model: \"openai/gpt-4.1-mini\",\n models: \"openai/gpt-4.1-mini, anthropic/claude-sonnet-4.5, google/gemini-2.5-pro\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"deepseek\",\n name: \"DeepSeek\",\n description: \"Use DeepSeek with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.deepseek.com/api_keys\",\n apiBaseUrl: \"https://api.deepseek.com/v1/chat/completions\",\n model: \"deepseek-chat\",\n models: \"deepseek-chat, deepseek-reasoner\",\n },\n },\n {\n id: \"mistral\",\n name: \"Mistral\",\n description: \"Use Mistral models with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"mistral\",\n name: \"Mistral\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.mistral.ai/api-keys\",\n apiBaseUrl: \"https://api.mistral.ai/v1/chat/completions\",\n model: \"mistral-large-latest\",\n models: \"mistral-large-latest, codestral-latest, ministral-8b-latest\",\n },\n },\n {\n id: \"aider\",\n name: \"Aider\",\n description: \"Use Aider with keys from OpenAI, Anthropic, Gemini, OpenRouter, DeepSeek, and more.\",\n provider: {\n id: \"aider\",\n name: \"Aider\",\n authType: \"cli\",\n command: \"aider\",\n signInCommand: \"aider\",\n setupUrl: \"https://aider.chat/docs/config/api-keys.html\",\n models: \"sonnet, opus, gpt-4.1, o4-mini, gemini/gemini-2.5-pro\",\n },\n },\n];\n\nconst DEFAULT_SETTINGS: AISidebarSettings = {\n providers: [\n cloneProvider(PROVIDER_PRESETS[0].provider),\n cloneProvider(PROVIDER_PRESETS[3].provider),\n ],\n defaultProviderId: \"codex\",\n defaultAccessMode: \"confirm\",\n sidebarHotkey: \"\",\n includeFolders: \"\",\n excludeFolders: \".obsidian, node_modules, .git\",\n maxContextChars: 45000,\n enableConversationMemory: true,\n maxMemoryMessages: 30,\n rememberedMessages: [],\n};\n\nexport default class AISidebarPlugin extends Plugin {\n settings: AISidebarSettings;\n\n async onload() {\n await this.loadSettings();\n\n this.registerView(\n VIEW_TYPE_AI_SIDEBAR,\n (leaf) => new AISidebarView(leaf, this),\n );\n\n this.addRibbonIcon(\"sparkles\", \"Toggle AI Sidebar\", () => {\n void this.toggleSidebar();\n });\n\n const toggleCommandHotkeys = this.settings.sidebarHotkey\n ? [parseHotkey(this.settings.sidebarHotkey)]\n : [];\n this.addCommand({\n id: \"toggle-ai-sidebar\",\n name: \"Toggle AI Sidebar\",\n hotkeys: toggleCommandHotkeys,\n callback: () => {\n void this.toggleSidebar();\n },\n });\n\n this.addSettingTab(new AISidebarSettingTab(this.app, this));\n }\n\n onunload() {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n }\n\n async toggleSidebar() {\n const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AI_SIDEBAR).first();\n if (existingLeaf) {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n return;\n }\n\n const leaf = this.app.workspace.getRightLeaf(false);\n if (!leaf) {\n new Notice(\"Could not open the AI sidebar.\");\n return;\n }\n\n await leaf.setViewState({ type: VIEW_TYPE_AI_SIDEBAR, active: true });\n this.app.workspace.revealLeaf(leaf);\n }\n\n async loadSettings() {\n const loaded = await this.loadData();\n this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);\n this.settings.providers = normalizeProviders(this.settings.providers);\n this.settings.rememberedMessages = Array.isArray(this.settings.rememberedMessages)\n ? this.settings.rememberedMessages\n : [];\n }\n\n async saveSettings() {\n await this.saveData(this.settings);\n }\n\n getProvider(id: string): AgentProvider | undefined {\n return this.settings.providers.find((provider) => provider.id === id);\n }\n\n async rememberMessages(messages: ChatMessage[]) {\n if (!this.settings.enableConversationMemory) return;\n this.settings.rememberedMessages = messages.slice(-this.settings.maxMemoryMessages);\n await this.saveSettings();\n }\n\n async clearMemory() {\n this.settings.rememberedMessages = [];\n await this.saveSettings();\n }\n\n async upsertProviderFromPreset(preset: ProviderPreset) {\n const provider = cloneProvider(preset.provider);\n const existingIndex = this.settings.providers.findIndex((item) => item.id === provider.id);\n if (existingIndex >= 0) {\n this.settings.providers[existingIndex] = {\n ...provider,\n apiKey: this.settings.providers[existingIndex].apiKey,\n accessToken: this.settings.providers[existingIndex].accessToken,\n refreshToken: this.settings.providers[existingIndex].refreshToken,\n };\n } else {\n this.settings.providers.push(provider);\n }\n this.settings.defaultProviderId = provider.id;\n await this.saveSettings();\n }\n}\n\nclass AISidebarView extends ItemView {\n private plugin: AISidebarPlugin;\n private messages: ChatMessage[] = [];\n private providerId: string;\n private accessMode: AccessMode;\n private threadEl: HTMLElement;\n private textareaEl: HTMLTextAreaElement;\n private contextEl: HTMLElement;\n private slashEl: HTMLElement;\n private modelBadgeEl: HTMLElement;\n private activeContextPath = \"\";\n private refreshTimer: number | undefined;\n private sendButton: ButtonComponent;\n private isRunning = false;\n\n constructor(leaf: WorkspaceLeaf, plugin: AISidebarPlugin) {\n super(leaf);\n this.plugin = plugin;\n this.messages = plugin.settings.enableConversationMemory\n ? [...plugin.settings.rememberedMessages]\n : [];\n this.providerId = plugin.settings.defaultProviderId;\n this.accessMode = plugin.settings.defaultAccessMode;\n }\n\n getViewType(): string {\n return VIEW_TYPE_AI_SIDEBAR;\n }\n\n getDisplayText(): string {\n return \"AI Sidebar\";\n }\n\n getIcon(): string {\n return \"sparkles\";\n }\n\n async onOpen() {\n this.render();\n this.registerEvent(\n this.app.workspace.on(\"active-leaf-change\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.workspace.on(\"file-open\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.vault.on(\"modify\", (file) => {\n if (file instanceof TFile && file.path === this.activeContextPath) {\n this.scheduleContextRefresh();\n }\n }),\n );\n await this.refreshContextPreview();\n }\n\n async onClose() {\n this.contentEl.empty();\n }\n\n private render() {\n this.contentEl.empty();\n this.contentEl.addClass(\"ai-sidebar\");\n\n const header = this.contentEl.createDiv(\"ai-sidebar__header\");\n header.createDiv({ cls: \"ai-sidebar__title\", text: \"AI Sidebar\" });\n header.createDiv({ cls: \"ai-sidebar__subtitle\", text: \"Vault-aware agents\" });\n\n const controls = this.contentEl.createDiv(\"ai-sidebar__controls\");\n new DropdownComponent(controls)\n .addOptions(this.providerOptions())\n .setValue(this.providerId)\n .onChange((value) => {\n this.providerId = value;\n this.updateModelBadge();\n });\n\n new DropdownComponent(controls)\n .addOptions({\n \"read-only\": \"Read only\",\n confirm: \"Confirm actions\",\n \"full-access\": \"Full access\",\n })\n .setValue(this.accessMode)\n .onChange((value: AccessMode) => {\n this.accessMode = value;\n void this.refreshContextPreview();\n });\n\n this.contextEl = this.contentEl.createDiv(\"ai-sidebar__context\");\n\n this.threadEl = this.contentEl.createDiv(\"ai-sidebar__thread\");\n this.renderMessages();\n\n const composer = this.contentEl.createDiv(\"ai-sidebar__composer\");\n this.textareaEl = composer.createEl(\"textarea\", {\n cls: \"ai-sidebar__input\",\n attr: {\n placeholder: \"Ask about your vault, draft a note, or request an edit...\",\n rows: \"4\",\n },\n });\n\n this.textareaEl.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Escape\" && !this.slashEl.hasClass(\"is-hidden\")) {\n event.preventDefault();\n this.hideSlashCommands();\n return;\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n void this.sendPrompt();\n }\n });\n this.textareaEl.addEventListener(\"input\", () => {\n void this.updateSlashCommands();\n this.updateModelBadge();\n });\n\n this.slashEl = composer.createDiv(\"ai-sidebar__slash is-hidden\");\n\n const actions = composer.createDiv(\"ai-sidebar__composer-actions\");\n new ButtonComponent(actions)\n .setButtonText(\"Refresh context\")\n .onClick(() => {\n void this.refreshContextPreview();\n });\n new ButtonComponent(actions)\n .setButtonText(\"Clear\")\n .onClick(() => {\n void this.clearConversation();\n });\n\n this.modelBadgeEl = actions.createDiv(\"ai-sidebar__model-badge\");\n this.updateModelBadge();\n\n this.sendButton = new ButtonComponent(actions)\n .setCta()\n .setButtonText(\"Send\")\n .onClick(() => {\n void this.sendPrompt();\n });\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderMessages() {\n this.threadEl.empty();\n\n if (this.messages.length === 0) {\n const empty = this.threadEl.createDiv(\"ai-sidebar__empty\");\n empty.createDiv({ text: \"Ask a local agent to work with the notes in this vault.\" });\n empty.createDiv({ text: \"Use confirm mode when you want to review file edits first.\" });\n return;\n }\n\n for (const message of this.messages) {\n const messageEl = this.threadEl.createDiv(`ai-sidebar__message ai-sidebar__message--${message.role}`);\n messageEl.createDiv({ cls: \"ai-sidebar__message-role\", text: message.role });\n messageEl.createEl(\"pre\", { text: message.content });\n }\n\n if (this.isRunning) {\n const thinkingEl = this.threadEl.createDiv(\"ai-sidebar__message ai-sidebar__message--thinking\");\n thinkingEl.createDiv({ cls: \"ai-sidebar__message-role\", text: \"assistant\" });\n const row = thinkingEl.createDiv(\"ai-sidebar__typing\");\n row.createSpan({ text: \"Thinking\" });\n const dots = row.createSpan(\"ai-sidebar__typing-dots\");\n dots.createSpan();\n dots.createSpan();\n dots.createSpan();\n }\n\n this.threadEl.scrollTo({ top: this.threadEl.scrollHeight });\n }\n\n private async refreshContextPreview(prompt = \"\") {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n prompt,\n this.extractSelectedSkillNames(prompt),\n );\n this.activeContextPath = context.activeFile?.path ?? \"\";\n this.contextEl.empty();\n this.contextEl.createDiv({\n cls: \"ai-sidebar__context-title\",\n text: `Context preview · ${this.accessModeLabel()}`,\n });\n\n const items = [\n context.activeFile ? `Active: ${context.activeFile.path}` : \"No active note\",\n context.selection ? \"Selection included\" : \"No selection\",\n `${context.linkedFiles.length} linked`,\n `${context.backlinkFiles.length} backlinks`,\n `${context.relevantFiles.length} relevant`,\n `${context.baseFiles.length} bases`,\n `${context.selectedSkills.length} skills`,\n ];\n\n const list = this.contextEl.createEl(\"ul\");\n for (const item of items) {\n list.createEl(\"li\", { text: item });\n }\n }\n\n private scheduleContextRefresh() {\n window.clearTimeout(this.refreshTimer);\n this.refreshTimer = window.setTimeout(() => {\n void this.refreshContextPreview(this.textareaEl?.value ?? \"\");\n }, 150);\n }\n\n private accessModeLabel(): string {\n if (this.accessMode === \"read-only\") return \"read only\";\n if (this.accessMode === \"full-access\") return \"full access\";\n return \"confirm actions\";\n }\n\n private async sendPrompt() {\n const rawPrompt = this.textareaEl.value.trim();\n if (!rawPrompt || this.isRunning) return;\n\n const provider = this.plugin.getProvider(this.providerId);\n if (!provider) {\n new Notice(\"Choose a provider in AI Sidebar settings.\");\n return;\n }\n\n if (!isProviderReady(provider)) {\n new Notice(\"Connect or configure this provider in AI Sidebar settings first.\");\n return;\n }\n\n const parsed = this.parsePromptControls(rawPrompt, provider);\n if (this.isSlashOnlyPrompt(rawPrompt, parsed)) {\n await this.applySlashControls(provider, parsed);\n this.textareaEl.value = \"\";\n this.updateModelBadge();\n await this.refreshContextPreview();\n return;\n }\n\n this.isRunning = true;\n this.sendButton.setButtonText(\"Running...\");\n this.sendButton.setDisabled(true);\n this.textareaEl.value = \"\";\n const effectiveAccessMode = parsed.accessMode ?? this.accessMode;\n this.messages.push({ role: \"user\", content: rawPrompt });\n this.renderMessages();\n\n try {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n parsed.cleanPrompt,\n parsed.selectedSkillNames,\n );\n await this.refreshContextPreview(rawPrompt);\n const request: AgentRequest = {\n prompt: parsed.cleanPrompt,\n accessMode: effectiveAccessMode,\n context,\n instructions: buildAgentInstructions(effectiveAccessMode),\n conversation: this.plugin.settings.enableConversationMemory ? this.messages.slice(0, -1) : [],\n options: parsed.options,\n };\n\n const response = await runProvider(provider, request, this.app);\n const actionResult = await this.handleActions(response);\n const assistantContent = actionResult ? `${response}\\n\\n${actionResult}` : response;\n this.messages.push({ role: \"assistant\", content: assistantContent.trim() || \"No response.\" });\n await this.plugin.rememberMessages(this.messages);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n this.messages.push({ role: \"system\", content: `Provider failed: ${message}` });\n await this.plugin.rememberMessages(this.messages);\n new Notice(\"AI provider failed. Check the sidebar for details.\");\n } finally {\n this.isRunning = false;\n this.sendButton.setButtonText(\"Send\");\n this.sendButton.setDisabled(false);\n this.updateModelBadge();\n this.renderMessages();\n }\n }\n\n private async clearConversation() {\n this.messages = [];\n await this.plugin.clearMemory();\n this.renderMessages();\n new Notice(\"AI Sidebar memory cleared.\");\n }\n\n private async handleActions(response: string): Promise<string> {\n const actions = extractVaultActions(response);\n if (actions.length === 0) return \"\";\n\n if (this.accessMode === \"read-only\") {\n return \"Vault actions were proposed, but read-only mode blocked all writes.\";\n }\n\n if (this.accessMode === \"confirm\") {\n const approved = await confirmVaultActions(this.app, actions);\n if (!approved) return \"Vault actions were not applied.\";\n }\n\n const results: string[] = [];\n for (const action of actions) {\n try {\n await applyVaultAction(this.app, action);\n results.push(`Applied ${action.type}: ${action.path}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n results.push(`Failed ${action.type}: ${action.path} · ${message}`);\n }\n }\n return results.join(\"\\n\");\n }\n\n private async updateSlashCommands() {\n const query = this.currentSlashQuery();\n if (query === null) {\n this.hideSlashCommands();\n return;\n }\n\n const provider = this.plugin.getProvider(this.providerId);\n const commands = provider ? nativeSlashCommands(provider, query, this.accessMode) : [];\n const skills = await listAgentSkills(this.plugin.app, query);\n this.slashEl.empty();\n\n if (commands.length === 0 && skills.length === 0) {\n this.hideSlashCommands();\n return;\n }\n\n this.slashEl.removeClass(\"is-hidden\");\n if (skills.length > 0) {\n this.slashEl.createDiv({ cls: \"ai-sidebar__slash-section\", text: \"Skills\" });\n }\n for (const skill of skills.slice(0, 12)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: `/${skill.name}` });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: skill.path });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(`/${skill.name}`);\n });\n }\n if (commands.length > 0) {\n this.slashEl.createDiv({ cls: \"ai-sidebar__slash-section\", text: \"Controls\" });\n }\n for (const command of commands.slice(0, 8)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: command.label });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: command.description });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(command.insert);\n });\n }\n }\n\n private hideSlashCommands() {\n this.slashEl.empty();\n this.slashEl.addClass(\"is-hidden\");\n }\n\n private currentSlashQuery(): string | null {\n const cursor = this.textareaEl.selectionStart;\n const beforeCursor = this.textareaEl.value.slice(0, cursor);\n const match = beforeCursor.match(/(?:^|\\s)\\/([A-Za-z0-9_.:-]*)$/);\n return match ? match[1].toLowerCase() : null;\n }\n\n private insertSlashToken(token: string) {\n const cursor = this.textareaEl.selectionStart;\n const value = this.textareaEl.value;\n const beforeCursor = value.slice(0, cursor);\n const afterCursor = value.slice(cursor);\n const replaced = beforeCursor.replace(/(?:^|\\s)\\/([A-Za-z0-9_.:-]*)$/, (match) => {\n const prefix = match.startsWith(\" \") ? \" \" : \"\";\n return `${prefix}${token} `;\n });\n this.textareaEl.value = `${replaced}${afterCursor}`;\n this.textareaEl.focus();\n this.textareaEl.selectionStart = replaced.length;\n this.textareaEl.selectionEnd = replaced.length;\n this.hideSlashCommands();\n this.scheduleContextRefresh();\n }\n\n private extractSelectedSkillNames(prompt: string): string[] {\n const native = new Set([\"model\", \"reasoning\", \"access\", \"memory\"]);\n return Array.from(prompt.matchAll(/(?:^|\\s)\\/([A-Za-z0-9_.-]+)/g), (match) => match[1])\n .filter((name) => !native.has(name.split(\":\")[0].toLowerCase()));\n }\n\n private parsePromptControls(prompt: string, provider: AgentProvider): ParsedPrompt {\n const options: AgentRequestOptions = {\n model: provider.model,\n reasoningEffort: provider.reasoningEffort,\n memoryEnabled: this.plugin.settings.enableConversationMemory,\n };\n let accessMode: AccessMode | undefined;\n\n const cleanPrompt = prompt.replace(/(?:^|\\s)\\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match, key, value) => {\n const normalizedKey = String(key).toLowerCase();\n const normalizedValue = String(value).toLowerCase();\n if (normalizedKey === \"model\") {\n options.model = value;\n } else if (normalizedKey === \"reasoning\" && isReasoningEffort(normalizedValue)) {\n options.reasoningEffort = normalizedValue;\n } else if (normalizedKey === \"access\" && isAccessMode(normalizedValue)) {\n accessMode = normalizedValue;\n } else if (normalizedKey === \"memory\") {\n options.memoryEnabled = normalizedValue === \"on\";\n }\n return match.startsWith(\" \") ? \" \" : \"\";\n }).trim();\n\n return {\n cleanPrompt,\n selectedSkillNames: this.extractSelectedSkillNames(prompt),\n options,\n accessMode,\n };\n }\n\n private isSlashOnlyPrompt(prompt: string, parsed: ParsedPrompt): boolean {\n return parsed.cleanPrompt.length === 0 && this.extractSelectedSkillNames(prompt).length === 0;\n }\n\n private async applySlashControls(provider: AgentProvider, parsed: ParsedPrompt) {\n const changes: string[] = [];\n if (parsed.options.model && parsed.options.model !== provider.model) {\n provider.model = parsed.options.model;\n changes.push(`model ${parsed.options.model}`);\n }\n if (parsed.options.reasoningEffort && parsed.options.reasoningEffort !== provider.reasoningEffort) {\n provider.reasoningEffort = parsed.options.reasoningEffort;\n changes.push(`reasoning ${parsed.options.reasoningEffort}`);\n }\n if (parsed.accessMode && parsed.accessMode !== this.accessMode) {\n this.accessMode = parsed.accessMode;\n this.plugin.settings.defaultAccessMode = parsed.accessMode;\n changes.push(`access ${this.accessModeLabel()}`);\n }\n if (parsed.options.memoryEnabled !== this.plugin.settings.enableConversationMemory) {\n this.plugin.settings.enableConversationMemory = parsed.options.memoryEnabled;\n changes.push(`memory ${parsed.options.memoryEnabled ? \"on\" : \"off\"}`);\n }\n await this.plugin.saveSettings();\n new Notice(changes.length > 0 ? `Updated ${changes.join(\", \")}.` : \"Slash command applied.\");\n }\n\n private updateModelBadge() {\n if (!this.modelBadgeEl) return;\n const provider = this.plugin.getProvider(this.providerId);\n const override = this.textareaEl?.value.match(/(?:^|\\s)\\/model:([A-Za-z0-9_.:/-]+)/i)?.[1];\n const model = override || provider?.model || parseProviderModels(provider ?? DEFAULT_SETTINGS.providers[0]).first() || \"default\";\n this.modelBadgeEl.setText(model);\n this.modelBadgeEl.toggleClass(\"is-override\", Boolean(override));\n this.modelBadgeEl.setAttr(\"aria-label\", override ? `Model override: ${model}` : `Model: ${model}`);\n }\n}\n\nclass AISidebarSettingTab extends PluginSettingTab {\n plugin: AISidebarPlugin;\n\n constructor(app: App, plugin: AISidebarPlugin) {\n super(app, plugin);\n this.plugin = plugin;\n }\n\n display(): void {\n const { containerEl } = this;\n containerEl.empty();\n containerEl.createEl(\"h2\", { text: \"AI Sidebar\" });\n\n new Setting(containerEl)\n .setName(\"Default provider\")\n .setDesc(\"The AI provider used when the sidebar opens.\")\n .addDropdown((dropdown) => {\n dropdown.addOptions(this.providerOptions());\n dropdown.setValue(this.plugin.settings.defaultProviderId);\n dropdown.onChange(async (value) => {\n this.plugin.settings.defaultProviderId = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Default vault access\")\n .setDesc(\"How much permission the agent has when it proposes changes.\")\n .addDropdown((dropdown) => {\n dropdown\n .addOption(\"read-only\", \"Read only\")\n .addOption(\"confirm\", \"Confirm actions\")\n .addOption(\"full-access\", \"Full access\")\n .setValue(this.plugin.settings.defaultAccessMode)\n .onChange(async (value: AccessMode) => {\n this.plugin.settings.defaultAccessMode = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Sidebar hotkey\")\n .setDesc(\"Optional shortcut for toggling the sidebar. Examples: Mod+Shift+A, Cmd+Option+Space, Ctrl+Alt+A. Reload the plugin after changing.\")\n .addText((text) => {\n text\n .setPlaceholder(\"Mod+Shift+A\")\n .setValue(this.plugin.settings.sidebarHotkey)\n .onChange(async (value) => {\n this.plugin.settings.sidebarHotkey = normalizeHotkey(value);\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Include folders\")\n .setDesc(\"Comma-separated folder prefixes to include. Leave blank for the whole vault.\")\n .addText((text) => {\n text\n .setPlaceholder(\"Projects, Daily Notes\")\n .setValue(this.plugin.settings.includeFolders)\n .onChange(async (value) => {\n this.plugin.settings.includeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Exclude folders\")\n .setDesc(\"Comma-separated folder prefixes that should never be sent to an agent.\")\n .addText((text) => {\n text\n .setValue(this.plugin.settings.excludeFolders)\n .onChange(async (value) => {\n this.plugin.settings.excludeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Max context characters\")\n .setDesc(\"Upper bound for note text sent to the agent.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxContextChars))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed > 1000) {\n this.plugin.settings.maxContextChars = parsed;\n await this.plugin.saveSettings();\n }\n });\n });\n\n new Setting(containerEl)\n .setName(\"Conversation memory\")\n .setDesc(\"Remember previous sidebar messages after closing and reopening Obsidian.\")\n .addToggle((toggle) => {\n toggle\n .setValue(this.plugin.settings.enableConversationMemory)\n .onChange(async (value) => {\n this.plugin.settings.enableConversationMemory = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Memory length\")\n .setDesc(\"Maximum number of recent sidebar messages to remember.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxMemoryMessages))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed >= 2) {\n this.plugin.settings.maxMemoryMessages = parsed;\n await this.plugin.saveSettings();\n }\n });\n })\n .addButton((button) => {\n button.setButtonText(\"Clear memory\").onClick(async () => {\n await this.plugin.clearMemory();\n new Notice(\"AI Sidebar memory cleared.\");\n });\n });\n\n containerEl.createEl(\"h3\", { text: \"Connect a Provider\" });\n this.renderProviderPresets(containerEl);\n\n containerEl.createEl(\"h3\", { text: \"Connected Providers\" });\n for (const provider of this.plugin.settings.providers) {\n this.renderProviderSetting(containerEl, provider);\n }\n\n new Setting(containerEl)\n .setName(\"Advanced provider\")\n .setDesc(\"Create a custom API, OAuth, or local CLI provider.\")\n .addButton((button) => {\n button.setButtonText(\"Add custom\").onClick(async () => {\n const id = `provider-${Date.now()}`;\n this.plugin.settings.providers.push({ id, name: \"New provider\", authType: \"oauth\", command: \"\" });\n await this.plugin.saveSettings();\n this.display();\n });\n });\n }\n\n private renderProviderPresets(containerEl: HTMLElement) {\n const grid = containerEl.createDiv(\"ai-sidebar-provider-grid\");\n for (const preset of PROVIDER_PRESETS) {\n const provider = this.plugin.getProvider(preset.provider.id);\n const card = grid.createDiv(\"ai-sidebar-provider-card\");\n card.createDiv({ cls: \"ai-sidebar-provider-card__name\", text: preset.name });\n card.createDiv({ cls: \"ai-sidebar-provider-card__desc\", text: preset.description });\n const status = provider ? providerConnectionLabel(provider) : \"Not added\";\n card.createDiv({ cls: \"ai-sidebar-provider-card__status\", text: status });\n\n const actions = card.createDiv(\"ai-sidebar-provider-card__actions\");\n new ButtonComponent(actions)\n .setButtonText(provider ? \"Use\" : \"Add\")\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n this.display();\n });\n\n new ButtonComponent(actions)\n .setCta()\n .setButtonText(providerConnectionActionText(provider ?? preset.provider))\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n const connectedProvider = this.plugin.getProvider(preset.provider.id) ?? preset.provider;\n if (await startProviderSignIn(connectedProvider)) {\n connectedProvider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n }\n this.display();\n });\n }\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderProviderSetting(containerEl: HTMLElement, provider: AgentProvider) {\n const wrapper = containerEl.createDiv(\"ai-sidebar-settings-provider\");\n const header = wrapper.createDiv(\"ai-sidebar-settings-provider__header\");\n const title = header.createDiv(\"ai-sidebar-settings-provider__title\");\n title.createDiv({ cls: \"ai-sidebar-settings-provider__name\", text: provider.name || provider.id });\n title.createDiv({ cls: \"ai-sidebar-settings-provider__desc\", text: this.providerDescription(provider) });\n header.createSpan({\n cls: `ai-sidebar-settings-provider__badge ${isProviderConnected(provider) ? \"is-connected\" : \"\"}`,\n text: providerConnectionLabel(provider),\n });\n\n const actions = wrapper.createDiv(\"ai-sidebar-settings-provider__actions\");\n new ButtonComponent(actions)\n .setButtonText(this.plugin.settings.defaultProviderId === provider.id ? \"Default\" : \"Use\")\n .setDisabled(this.plugin.settings.defaultProviderId === provider.id)\n .onClick(async () => {\n this.plugin.settings.defaultProviderId = provider.id;\n await this.plugin.saveSettings();\n this.display();\n });\n this.configureConnectButton(new ButtonComponent(actions), provider);\n new ButtonComponent(actions)\n .setIcon(\"trash\")\n .setTooltip(\"Remove provider\")\n .onClick(async () => {\n this.plugin.settings.providers = this.plugin.settings.providers.filter((item) => item !== provider);\n if (this.plugin.settings.defaultProviderId === provider.id) {\n this.plugin.settings.defaultProviderId = this.plugin.settings.providers.first()?.id ?? \"\";\n }\n await this.plugin.saveSettings();\n this.display();\n });\n\n const fields = wrapper.createDiv(\"ai-sidebar-settings-provider__fields\");\n this.renderTextField(fields, \"Name\", provider.name, async (value) => {\n provider.name = value;\n await this.plugin.saveSettings();\n this.display();\n });\n\n if (provider.authType === \"cli\") {\n this.renderTextField(fields, \"Command\", provider.command, async (value) => {\n provider.command = value;\n await this.plugin.saveSettings();\n });\n if (provider.signInCommand) {\n this.renderTextField(fields, \"Sign-in command\", provider.signInCommand, async (value) => {\n provider.signInCommand = value;\n provider.connectedAt = undefined;\n await this.plugin.saveSettings();\n });\n }\n }\n\n if (provider.authType === \"api-key\") {\n this.renderTextField(fields, \"API key\", provider.apiKey ?? \"\", async (value) => {\n provider.apiKey = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n this.renderTextField(fields, \"Endpoint\", provider.apiBaseUrl ?? \"\", async (value) => {\n provider.apiBaseUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Default model\", provider.model ?? \"\", async (value) => {\n provider.model = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Slash models\", provider.models ?? \"\", async (value) => {\n provider.models = value;\n await this.plugin.saveSettings();\n });\n }\n\n if (provider.authType === \"oauth\") {\n this.renderTextField(fields, \"Authorization URL\", provider.authorizationUrl ?? \"\", async (value) => {\n provider.authorizationUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Client ID\", provider.clientId ?? \"\", async (value) => {\n provider.clientId = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Access token\", provider.accessToken ?? \"\", async (value) => {\n provider.accessToken = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n }\n }\n\n private renderTextField(\n containerEl: HTMLElement,\n label: string,\n value: string,\n onChange: (value: string) => Promise<void>,\n secret = false,\n ) {\n const field = containerEl.createDiv(\"ai-sidebar-settings-field\");\n field.createDiv({ cls: \"ai-sidebar-settings-field__label\", text: label });\n const input = field.createEl(\"input\", {\n cls: \"ai-sidebar-settings-field__input\",\n attr: { type: secret ? \"password\" : \"text\" },\n });\n input.value = value;\n input.addEventListener(\"change\", () => {\n void onChange(input.value);\n });\n }\n\n private providerDescription(provider: AgentProvider): string {\n if (provider.authType === \"api-key\") {\n return provider.apiKey ? \"Connected with API key.\" : \"Add an API key to connect.\";\n }\n if (provider.authType === \"oauth\") {\n return provider.accessToken ? \"Connected with OAuth.\" : \"Connect with OAuth when the provider exposes a desktop OAuth app.\";\n }\n return provider.command ? `Runs local command: ${provider.command}` : \"Add the local CLI command.\";\n }\n\n private configureConnectButton(button: ButtonComponent, provider: AgentProvider) {\n button\n .setButtonText(providerConnectionActionText(provider))\n .onClick(async () => {\n if (await startProviderSignIn(provider)) {\n provider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n this.display();\n }\n });\n }\n}\n\nclass ConfirmActionsModal extends Modal {\n private actions: VaultAction[];\n private resolve: (approved: boolean) => void;\n\n constructor(app: App, actions: VaultAction[], resolve: (approved: boolean) => void) {\n super(app);\n this.actions = actions;\n this.resolve = resolve;\n }\n\n onOpen() {\n const { contentEl } = this;\n contentEl.empty();\n contentEl.createEl(\"h2\", { text: \"Apply AI vault actions?\" });\n contentEl.createEl(\"p\", { text: \"Review these proposed file changes before they are applied.\" });\n\n for (const action of this.actions) {\n const actionEl = contentEl.createDiv(\"ai-sidebar-action-preview\");\n actionEl.createEl(\"strong\", { text: `${action.type}: ${action.path}` });\n if (action.type === \"rename\") {\n actionEl.createDiv({ text: `New path: ${action.newPath}` });\n }\n if (\"content\" in action) {\n actionEl.createEl(\"pre\", { text: action.content.slice(0, 1200) });\n }\n }\n\n new Setting(contentEl)\n .addButton((button) => {\n button.setButtonText(\"Cancel\").onClick(() => {\n this.resolve(false);\n this.close();\n });\n })\n .addButton((button) => {\n button.setCta().setButtonText(\"Apply\").onClick(() => {\n this.resolve(true);\n this.close();\n });\n });\n }\n\n onClose() {\n this.contentEl.empty();\n }\n}\n\nasync function collectVaultContext(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n selectedSkillNames: string[] = [],\n): Promise<VaultContext> {\n const activeView = app.workspace.getActiveViewOfType(MarkdownView);\n const activeFile = activeView?.file ?? app.workspace.getActiveFile();\n const selection = activeView?.editor.getSelection() || undefined;\n const budget = new ContextBudget(settings.maxContextChars);\n\n const context: VaultContext = {\n selection,\n linkedFiles: [],\n backlinkFiles: [],\n relevantFiles: [],\n baseFiles: [],\n selectedSkills: [],\n };\n\n const activeContext = activeFile ? await readContextFile(app, activeFile, \"active note\", budget) : undefined;\n if (activeContext) context.activeFile = activeContext;\n\n const linked = activeFile ? getLinkedFiles(app, activeFile) : [];\n for (const file of linked) {\n const item = await readContextFile(app, file, \"linked note\", budget);\n if (item) context.linkedFiles.push(item);\n }\n\n const backlinks = activeFile ? getBacklinkFiles(app, activeFile) : [];\n for (const file of backlinks) {\n const item = await readContextFile(app, file, \"backlink\", budget);\n if (item) context.backlinkFiles.push(item);\n }\n\n const baseFiles = app.vault\n .getFiles()\n .filter((file) => file.extension === \"base\" && shouldIncludeFile(file, settings))\n .slice(0, 5);\n for (const file of baseFiles) {\n const item = await readContextFile(app, file, \"base file\", budget);\n if (item) context.baseFiles.push(item);\n }\n\n const relatedFiles = await findRelevantFiles(app, settings, prompt, activeFile, budget.remaining());\n for (const file of relatedFiles) {\n const item = await readContextFile(app, file, \"relevant match\", budget);\n if (item) context.relevantFiles.push(item);\n }\n\n context.selectedSkills = await readSelectedSkills(app, selectedSkillNames, budget);\n\n return context;\n}\n\nasync function readContextFile(\n app: App,\n file: TFile,\n reason: string,\n budget: ContextBudget,\n): Promise<VaultContextFile | undefined> {\n if (!budget.hasRoom()) return undefined;\n const content = await app.vault.cachedRead(file);\n const clipped = budget.take(content);\n return { path: file.path, content: clipped, reason };\n}\n\nfunction getLinkedFiles(app: App, activeFile: TFile): TFile[] {\n const cache = app.metadataCache.getFileCache(activeFile);\n const links = cache?.links ?? [];\n const files: TFile[] = [];\n\n for (const link of links) {\n const linkedFile = app.metadataCache.getFirstLinkpathDest(link.link, activeFile.path);\n if (linkedFile) files.push(linkedFile);\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nfunction getBacklinkFiles(app: App, activeFile: TFile): TFile[] {\n const resolvedLinks = app.metadataCache.resolvedLinks;\n const files: TFile[] = [];\n\n for (const [sourcePath, targets] of Object.entries(resolvedLinks)) {\n if (targets[activeFile.path]) {\n const file = app.vault.getAbstractFileByPath(sourcePath);\n if (file instanceof TFile) files.push(file);\n }\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nasync function findRelevantFiles(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n activeFile: TFile | null,\n maxChars: number,\n): Promise<TFile[]> {\n if (!prompt.trim() || maxChars <= 0) return [];\n const terms = prompt\n .toLowerCase()\n .split(/[^a-z0-9/_-]+/i)\n .filter((term) => term.length > 2)\n .slice(0, 16);\n if (terms.length === 0) return [];\n\n const scored: Array<{ file: TFile; score: number }> = [];\n const files = app.vault\n .getFiles()\n .filter((file) => file !== activeFile && shouldIncludeFile(file, settings) && isReadableContextFile(file));\n\n for (const file of files) {\n const content = (await app.vault.cachedRead(file)).toLowerCase();\n const path = file.path.toLowerCase();\n let score = 0;\n for (const term of terms) {\n if (path.includes(term)) score += 4;\n const matches = content.match(new RegExp(escapeRegExp(term), \"g\"));\n score += matches ? Math.min(matches.length, 8) : 0;\n }\n if (score > 0) scored.push({ file, score });\n }\n\n return scored\n .sort((a, b) => b.score - a.score)\n .slice(0, 10)\n .map((item) => item.file);\n}\n\nfunction shouldIncludeFile(file: TFile, settings: AISidebarSettings): boolean {\n const includes = parseFolderList(settings.includeFolders);\n const excludes = parseFolderList(settings.excludeFolders);\n if (includes.length > 0 && !includes.some((folder) => file.path.startsWith(folder))) return false;\n if (excludes.some((folder) => file.path.startsWith(folder))) return false;\n return isReadableContextFile(file);\n}\n\nfunction isReadableContextFile(file: TFile): boolean {\n return [\"md\", \"canvas\", \"base\", \"json\", \"txt\"].includes(file.extension);\n}\n\nfunction nativeSlashCommands(\n provider: AgentProvider,\n query: string,\n currentAccessMode: AccessMode,\n): Array<{ label: string; description: string; insert: string }> {\n const commands: Array<{ label: string; description: string; insert: string }> = [];\n const modelChoices = parseProviderModels(provider);\n\n for (const model of modelChoices) {\n commands.push({\n label: `/model:${model}`,\n description: `Use ${model} for this request`,\n insert: `/model:${model}`,\n });\n }\n\n if (provider.authType === \"api-key\" || provider.id.includes(\"codex\") || provider.id.includes(\"openai\")) {\n for (const effort of [\"low\", \"medium\", \"high\"] as ReasoningEffort[]) {\n commands.push({\n label: `/reasoning:${effort}`,\n description: `Set reasoning effort to ${effort}`,\n insert: `/reasoning:${effort}`,\n });\n }\n }\n\n for (const mode of [\"read-only\", \"confirm\", \"full-access\"] as AccessMode[]) {\n commands.push({\n label: `/access:${mode}`,\n description: mode === currentAccessMode ? \"Current vault access mode\" : `Use ${mode} for this request`,\n insert: `/access:${mode}`,\n });\n }\n\n commands.push(\n { label: \"/memory:on\", description: \"Include remembered chat history for this request\", insert: \"/memory:on\" },\n { label: \"/memory:off\", description: \"Do not include remembered chat history for this request\", insert: \"/memory:off\" },\n );\n\n const normalizedQuery = query.toLowerCase();\n return commands.filter((command) => command.label.slice(1).toLowerCase().includes(normalizedQuery));\n}\n\nfunction parseProviderModels(provider: AgentProvider): string[] {\n const configured = provider.models\n ?.split(\",\")\n .map((model) => model.trim())\n .filter(Boolean) ?? [];\n const models = configured.length > 0 ? configured : [provider.model].filter((model): model is string => Boolean(model));\n return Array.from(new Set(models)).slice(0, 12);\n}\n\nfunction parseHotkey(value: string): Hotkey {\n const normalized = normalizeHotkey(value);\n const parts = normalized.split(\"+\").filter(Boolean);\n const key = parts.pop() || \"A\";\n return {\n modifiers: parts as Modifier[],\n key,\n };\n}\n\nfunction normalizeHotkey(value: string): string {\n const aliases: Record<string, string> = {\n command: \"Mod\",\n cmd: \"Mod\",\n meta: \"Mod\",\n mod: \"Mod\",\n control: \"Ctrl\",\n ctrl: \"Ctrl\",\n option: \"Alt\",\n opt: \"Alt\",\n alt: \"Alt\",\n shift: \"Shift\",\n };\n const parts = value\n .split(\"+\")\n .map((part) => part.trim())\n .filter(Boolean);\n if (parts.length === 0) return \"\";\n\n const key = parts.pop() ?? \"A\";\n const modifiers = parts\n .map((part) => aliases[part.toLowerCase()] ?? part)\n .filter((part, index, all) => isModifier(part) && all.indexOf(part) === index);\n return [...modifiers, normalizeHotkeyKey(key)].join(\"+\");\n}\n\nfunction isModifier(value: string): value is Modifier {\n return value === \"Mod\" || value === \"Ctrl\" || value === \"Alt\" || value === \"Shift\";\n}\n\nfunction normalizeHotkeyKey(key: string): string {\n const lower = key.toLowerCase();\n if (lower === \"space\" || key === \" \") return \"Space\";\n if (lower === \"enter\" || lower === \"return\") return \"Enter\";\n if (lower === \"escape\" || lower === \"esc\") return \"Escape\";\n return key.length === 1 ? key.toUpperCase() : key;\n}\n\nasync function listAgentSkills(app: App, query = \"\"): Promise<SkillReference[]> {\n const normalizedQuery = query.toLowerCase();\n const vaultSkills = app.vault\n .getFiles()\n .filter((file) => isSkillFile(file))\n .map((file) => ({\n name: skillNameFromPath(file.path),\n path: file.path,\n source: \"vault\" as const,\n }));\n const adapterSkills = await listAgentSkillsFromAdapter(app);\n const globalSkills = await listGlobalAgentSkills();\n const skills = [...vaultSkills, ...adapterSkills, ...globalSkills]\n .filter((skill) => !normalizedQuery || skill.name.toLowerCase().includes(normalizedQuery))\n .sort((a, b) => a.name.localeCompare(b.name));\n\n const seen = new Set<string>();\n return skills.filter((skill) => {\n const key = `${skill.source}:${skill.name.toLowerCase()}`;\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\nasync function listAgentSkillsFromAdapter(app: App): Promise<SkillReference[]> {\n const roots = [\".agent/skills\", \".agents/skills\", \"agent/skills\", \"agents/skills\"];\n const files: string[] = [];\n for (const root of roots) {\n files.push(...await listAdapterFiles(app, root));\n }\n return files\n .filter((path) => isSkillPath(path))\n .map((path) => ({\n name: skillNameFromPath(path),\n path,\n source: \"vault\" as const,\n }));\n}\n\nasync function listGlobalAgentSkills(): Promise<SkillReference[]> {\n const roots = [\n path.join(homedir(), \".agent\", \"skills\", \"skills\"),\n path.join(homedir(), \".agents\", \"skills\", \"skills\"),\n ];\n const files: string[] = [];\n for (const root of roots) {\n files.push(...await listFilesystemFiles(root));\n }\n return files\n .filter((filePath) => isGlobalSkillPath(filePath))\n .map((filePath) => ({\n name: skillNameFromPath(filePath),\n path: filePath,\n source: \"global\" as const,\n }));\n}\n\nasync function listFilesystemFiles(folder: string): Promise<string[]> {\n try {\n const entries = await fs.readdir(folder, { withFileTypes: true });\n const results = await Promise.all(entries.map(async (entry) => {\n const childPath = path.join(folder, entry.name);\n if (entry.isDirectory()) return listFilesystemFiles(childPath);\n if (entry.isFile()) return [childPath];\n return [];\n }));\n return results.flat();\n } catch {\n return [];\n }\n}\n\nasync function listAdapterFiles(app: App, folder: string): Promise<string[]> {\n try {\n const listed = await app.vault.adapter.list(folder);\n const childFiles = listed.files;\n const nested = await Promise.all(listed.folders.map((child) => listAdapterFiles(app, child)));\n return [...childFiles, ...nested.flat()];\n } catch {\n return [];\n }\n}\n\nasync function readSelectedSkills(\n app: App,\n selectedSkillNames: string[],\n budget: ContextBudget,\n): Promise<AgentSkill[]> {\n const names = new Set(selectedSkillNames.map((name) => name.toLowerCase()));\n if (names.size === 0) return [];\n\n const skills: AgentSkill[] = [];\n const skillFiles = [...await listAgentSkillsFromAdapter(app), ...await listGlobalAgentSkills()];\n const vaultFiles = app.vault\n .getFiles()\n .filter((candidate) => isSkillFile(candidate))\n .map((file) => ({ name: skillNameFromPath(file.path), path: file.path, source: \"vault\" as const }));\n for (const skillFile of [...vaultFiles, ...skillFiles]) {\n const name = skillFile.name;\n if (!names.has(name.toLowerCase()) || !budget.hasRoom()) continue;\n const content = await readSkillContent(app, skillFile);\n skills.push({\n name,\n path: skillFile.path,\n content: budget.take(content),\n });\n }\n return skills;\n}\n\nasync function readSkillContent(app: App, skill: SkillReference): Promise<string> {\n if (skill.source === \"global\") return fs.readFile(skill.path, \"utf8\");\n const file = app.vault.getAbstractFileByPath(skill.path);\n if (file instanceof TFile) return app.vault.cachedRead(file);\n return app.vault.adapter.read(skill.path);\n}\n\nfunction isSkillFile(file: TFile): boolean {\n return isSkillPath(file.path);\n}\n\nfunction isSkillPath(path: string): boolean {\n if (!hasSupportedSkillExtension(path)) return false;\n const normalized = path.replace(/^\\/+/, \"\");\n return normalized.startsWith(\".agent/skills/\")\n || normalized.startsWith(\".agents/skills/\")\n || normalized.startsWith(\"agent/skills/\")\n || normalized.startsWith(\"agents/skills/\");\n}\n\nfunction hasSupportedSkillExtension(path: string): boolean {\n const extension = path.split(\".\").last()?.toLowerCase() ?? \"\";\n return [\"md\", \"txt\", \"json\", \"yaml\", \"yml\", \"prompt\"].includes(extension);\n}\n\nfunction isGlobalSkillPath(filePath: string): boolean {\n return path.basename(filePath).toLowerCase() === \"skill.md\";\n}\n\nfunction skillNameFromPath(path: string): string {\n const parts = path.split(\"/\");\n const fileName = parts.last() ?? path;\n if (fileName.toLowerCase() === \"skill.md\" && parts.length > 1) {\n return parts[parts.length - 2];\n }\n return fileName.replace(/\\.[^.]+$/, \"\");\n}\n\nfunction parseFolderList(value: string): string[] {\n return value\n .split(\",\")\n .map((folder) => folder.trim().replace(/^\\/+/, \"\"))\n .filter(Boolean);\n}\n\nclass ContextBudget {\n private remainingChars: number;\n\n constructor(maxChars: number) {\n this.remainingChars = maxChars;\n }\n\n hasRoom(): boolean {\n return this.remainingChars > 0;\n }\n\n remaining(): number {\n return this.remainingChars;\n }\n\n take(content: string): string {\n const clipped = content.slice(0, this.remainingChars);\n this.remainingChars -= clipped.length;\n return clipped;\n }\n}\n\nfunction buildAgentInstructions(accessMode: AccessMode): string {\n return [\n \"You are running inside an Obsidian AI Sidebar plugin.\",\n \"Use the provided vault context to answer the user.\",\n \"If the prompt contains slash commands such as /writer or /reviewer, apply the matching selectedSkills instructions from the request context.\",\n \"The request may include conversation memory and per-request options such as model or reasoning effort.\",\n \"When you need file changes, include a JSON code block with this shape:\",\n '{\"actions\":[{\"type\":\"edit\",\"path\":\"Note.md\",\"content\":\"new content\"}]}',\n \"Allowed action types: create, edit, append, delete, rename.\",\n `Current access mode: ${accessMode}. The plugin enforces this mode before applying actions.`,\n ].join(\"\\n\");\n}\n\nasync function runProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n if (provider.authType === \"api-key\") {\n return runOpenAICompatibleProvider(provider, request);\n }\n\n if (provider.authType === \"oauth\") {\n if (!provider.accessToken) {\n throw new Error(\"This OAuth provider is not connected yet.\");\n }\n return runOAuthProvider(provider, request);\n }\n\n return runCliProvider(provider, request, app);\n}\n\nasync function runOpenAICompatibleProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiKey) throw new Error(\"Missing API key.\");\n const url = provider.apiBaseUrl || \"https://api.openai.com/v1/responses\";\n const model = request.options.model || provider.model || \"gpt-4.1-mini\";\n const userPayload = JSON.stringify({\n prompt: request.prompt,\n accessMode: request.accessMode,\n context: request.context,\n options: request.options,\n });\n const messages = [\n { role: \"system\", content: request.instructions },\n ...request.conversation.map((message) => ({\n role: message.role === \"assistant\" ? \"assistant\" : \"user\",\n content: message.content,\n })),\n { role: \"user\", content: userPayload },\n ];\n const usesChatCompletions = url.includes(\"/chat/completions\");\n const body: Record<string, unknown> = usesChatCompletions\n ? { model, messages }\n : { model, input: messages };\n if (request.options.reasoningEffort && model.startsWith(\"o\")) {\n body.reasoning = { effort: request.options.reasoningEffort };\n }\n const response = await requestUrl({\n url,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n return extractOpenAIText(response.json);\n}\n\nasync function runOAuthProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiBaseUrl) {\n throw new Error(\"OAuth provider needs an API endpoint after login.\");\n }\n const response = await requestUrl({\n url: provider.apiBaseUrl,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.accessToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(request),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n if (typeof response.json?.text === \"string\") return response.json.text;\n if (typeof response.json?.output_text === \"string\") return response.json.output_text;\n return response.text;\n}\n\nfunction runCliProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n return new Promise((resolve, reject) => {\n const prompt = cliPromptFromRequest(request);\n const command = cliCommandForRequest(provider, request, app);\n const shellCommand = `${command} ${shellQuote(prompt)}`;\n const child = spawn(\"/bin/zsh\", [\"-lc\", shellCommand], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n let stdout = \"\";\n let stderr = \"\";\n\n child.stdout.on(\"data\", (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) {\n resolve(stdout.trim());\n } else {\n reject(new Error(stderr.trim() || `Command exited with code ${code}. Check that ${command} is installed and available in your shell.`));\n }\n });\n\n });\n}\n\nfunction cliCommandForRequest(provider: AgentProvider, request: AgentRequest, app: App): string {\n const vaultPath = getVaultPath(app);\n if (provider.id === \"codex\" || provider.command.startsWith(\"codex exec\")) {\n const sandbox = providerSandboxForAccessMode(request.accessMode);\n const cdArg = vaultPath ? ` -C ${shellQuote(vaultPath)}` : \"\";\n return `${provider.command}${cdArg} --skip-git-repo-check -s ${sandbox}`;\n }\n return provider.command;\n}\n\nfunction providerSandboxForAccessMode(accessMode: AccessMode): string {\n if (accessMode === \"full-access\") return \"workspace-write\";\n return \"read-only\";\n}\n\nfunction getVaultPath(app: App): string | undefined {\n const adapter = app.vault.adapter;\n if (\"basePath\" in adapter && typeof adapter.basePath === \"string\") {\n return adapter.basePath;\n }\n return undefined;\n}\n\nfunction cliPromptFromRequest(request: AgentRequest): string {\n return [\n request.instructions,\n \"\",\n \"User request:\",\n request.prompt,\n \"\",\n \"Vault context and options:\",\n JSON.stringify({\n accessMode: request.accessMode,\n options: request.options,\n conversation: request.options.memoryEnabled ? request.conversation : [],\n context: request.context,\n }, null, 2),\n ].join(\"\\n\");\n}\n\nfunction openInteractiveTerminal(command: string, title: string): Promise<void> {\n const escapedCommand = escapeForSingleQuotedShell(command);\n const script = [\n `tell application \"Terminal\"`,\n \"activate\",\n `do script \"printf '\\\\\\\\033]0;${escapeForAppleScript(title)}\\\\\\\\007'; clear; echo 'AI Sidebar sign-in'; echo ''; echo 'Running: ${escapeForAppleScript(command)}'; echo ''; /bin/zsh -lc '${escapedCommand}; echo; echo Sign-in command finished. You can close this window when done.'\"`\n ,\n \"end tell\",\n ].join(\"\\n\");\n\n return new Promise((resolve, reject) => {\n const child = spawn(\"osascript\", [\"-e\", script], {\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n });\n let stderr = \"\";\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) resolve();\n else reject(new Error(stderr.trim() || `Could not open Terminal. Exit code ${code}`));\n });\n });\n}\n\nfunction extractOpenAIText(json: unknown): string {\n if (!json || typeof json !== \"object\") return \"\";\n const response = json as {\n output_text?: string;\n output?: Array<{ content?: Array<{ text?: string; type?: string }> }>;\n choices?: Array<{ message?: { content?: string } }>;\n };\n if (typeof response.output_text === \"string\") return response.output_text;\n if (typeof response.choices?.[0]?.message?.content === \"string\") {\n return response.choices[0].message.content;\n }\n const parts: string[] = [];\n for (const item of response.output ?? []) {\n for (const content of item.content ?? []) {\n if (typeof content.text === \"string\") parts.push(content.text);\n }\n }\n return parts.join(\"\\n\");\n}\n\nasync function startOAuthLogin(provider: AgentProvider): Promise<boolean> {\n if (!provider.authorizationUrl || !provider.clientId) {\n new Notice(\"Add an authorization URL and client ID first.\");\n return false;\n }\n\n const url = new URL(provider.authorizationUrl);\n url.searchParams.set(\"response_type\", \"token\");\n url.searchParams.set(\"client_id\", provider.clientId);\n url.searchParams.set(\"redirect_uri\", \"urn:ietf:wg:oauth:2.0:oob\");\n if (provider.scope) url.searchParams.set(\"scope\", provider.scope);\n url.searchParams.set(\"state\", crypto.randomUUID());\n\n window.open(url.toString());\n new Notice(\"OAuth sign-in opened in your browser. Paste the returned token into this provider when available.\");\n return true;\n}\n\nasync function startProviderSignIn(provider: AgentProvider): Promise<boolean> {\n if (provider.authType === \"cli\") {\n if (!provider.signInCommand) {\n new Notice(\"This local provider does not have a sign-in command configured.\");\n return false;\n }\n try {\n await openInteractiveTerminal(provider.signInCommand, `${provider.name} Sign In`);\n new Notice(`Opened Terminal for ${provider.name} sign-in.`);\n return true;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n new Notice(`Could not open sign-in: ${message}`);\n return false;\n }\n }\n\n if (provider.authType === \"api-key\") {\n if (provider.setupUrl) window.open(provider.setupUrl);\n new Notice(provider.apiKey ? `${provider.name} is connected.` : `Add your ${provider.name} API key below to connect.`);\n return Boolean(provider.apiKey);\n }\n\n return startOAuthLogin(provider);\n}\n\nfunction escapeForSingleQuotedShell(value: string): string {\n return value.replace(/'/g, \"'\\\\''\");\n}\n\nfunction shellQuote(value: string): string {\n return `'${escapeForSingleQuotedShell(value)}'`;\n}\n\nfunction escapeForAppleScript(value: string): string {\n return value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\nfunction providerConnectionActionText(provider: AgentProvider): string {\n if (provider.authType === \"cli\") return isProviderConnected(provider) ? \"Reconnect\" : \"Sign in\";\n if (provider.authType === \"api-key\") return provider.apiKey ? \"Connected\" : \"Get key\";\n return provider.accessToken ? \"Reconnect\" : \"Sign in\";\n}\n\nfunction providerConnectionLabel(provider: AgentProvider): string {\n if (isProviderConnected(provider)) return \"Connected\";\n if (provider.authType === \"api-key\") return \"Needs API key\";\n if (provider.authType === \"oauth\") return \"Needs sign-in\";\n return provider.signInCommand ? \"Ready to sign in\" : \"Needs command\";\n}\n\nfunction isProviderConnected(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken);\n return Boolean(provider.connectedAt);\n}\n\nfunction isProviderReady(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey && provider.apiBaseUrl);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken && provider.apiBaseUrl);\n return Boolean(provider.command.trim());\n}\n\nfunction normalizeProviders(providers: AgentProvider[]): AgentProvider[] {\n return providers.map((provider) => {\n const migrated = provider as AgentProvider;\n const command = migrated.id === \"codex\" && migrated.command === \"codex\"\n ? \"codex exec\"\n : migrated.command ?? \"\";\n return {\n ...migrated,\n authType: migrated.authType ?? (migrated.command ? \"cli\" : \"api-key\"),\n command,\n connectedAt: migrated.connectedAt,\n };\n });\n}\n\nfunction cloneProvider(provider: AgentProvider): AgentProvider {\n return { ...provider };\n}\n\nfunction extractVaultActions(response: string): VaultAction[] {\n const actions: VaultAction[] = [];\n const codeBlockPattern = /```(?:json)?\\s*([\\s\\S]*?)```/gi;\n const candidates: string[] = [];\n let match: RegExpExecArray | null;\n\n while ((match = codeBlockPattern.exec(response)) !== null) {\n candidates.push(match[1]);\n }\n candidates.push(response);\n\n for (const candidate of candidates) {\n try {\n const parsed = JSON.parse(candidate.trim()) as { actions?: VaultAction[] };\n if (Array.isArray(parsed.actions)) {\n actions.push(...parsed.actions.filter(isVaultAction));\n }\n } catch {\n continue;\n }\n }\n\n return actions;\n}\n\nfunction isVaultAction(value: unknown): value is VaultAction {\n if (!value || typeof value !== \"object\") return false;\n const action = value as Partial<VaultAction>;\n if (typeof action.type !== \"string\" || typeof action.path !== \"string\") return false;\n if ([\"create\", \"edit\", \"append\"].includes(action.type)) {\n return typeof (action as { content?: unknown }).content === \"string\";\n }\n if (action.type === \"delete\") return true;\n if (action.type === \"rename\") {\n return typeof (action as { newPath?: unknown }).newPath === \"string\";\n }\n return false;\n}\n\nfunction confirmVaultActions(app: App, actions: VaultAction[]): Promise<boolean> {\n return new Promise((resolve) => {\n new ConfirmActionsModal(app, actions, resolve).open();\n });\n}\n\nasync function applyVaultAction(app: App, action: VaultAction): Promise<void> {\n const path = normalizeVaultPath(action.path);\n const file = app.vault.getAbstractFileByPath(path);\n\n if (action.type === \"create\") {\n if (file) throw new Error(\"File already exists.\");\n await ensureParentFolder(app, path);\n await app.vault.create(path, action.content);\n return;\n }\n\n if (!(file instanceof TFile)) {\n throw new Error(\"File does not exist.\");\n }\n\n if (action.type === \"edit\") {\n await app.vault.modify(file, action.content);\n } else if (action.type === \"append\") {\n const current = await app.vault.read(file);\n await app.vault.modify(file, `${current}${current.endsWith(\"\\n\") ? \"\" : \"\\n\"}${action.content}`);\n } else if (action.type === \"delete\") {\n await app.vault.delete(file);\n } else if (action.type === \"rename\") {\n await ensureParentFolder(app, action.newPath);\n await app.fileManager.renameFile(file, normalizeVaultPath(action.newPath));\n }\n}\n\nasync function ensureParentFolder(app: App, path: string): Promise<void> {\n const parts = normalizeVaultPath(path).split(\"/\");\n parts.pop();\n let current = \"\";\n for (const part of parts) {\n current = current ? `${current}/${part}` : part;\n if (!app.vault.getAbstractFileByPath(current)) {\n await app.vault.createFolder(current);\n }\n }\n}\n\nfunction normalizeVaultPath(path: string): string {\n return path.replace(/^\\/+/, \"\").replace(/\\\\/g, \"/\");\n}\n\nfunction uniqueFiles(files: TFile[]): TFile[] {\n const seen = new Set<string>();\n return files.filter((file) => {\n if (seen.has(file.path)) return false;\n seen.add(file.path);\n return true;\n });\n}\n\nfunction sanitizeProviderId(value: string): string {\n return value.toLowerCase().replace(/[^a-z0-9_-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || \"provider\";\n}\n\nfunction isReasoningEffort(value: string): value is ReasoningEffort {\n return value === \"low\" || value === \"medium\" || value === \"high\";\n}\n\nfunction isAccessMode(value: string): value is AccessMode {\n return value === \"read-only\" || value === \"confirm\" || value === \"full-access\";\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"}, "7ef29d1d3f12c765c2198cc619f8a4107186cc93": {"t": 1785759149.342294, "n": 67684, "h": "36dd7d6a186cdda1", "c": "import {\n App,\n ButtonComponent,\n DropdownComponent,\n ItemView,\n MarkdownView,\n Modal,\n Notice,\n Plugin,\n PluginSettingTab,\n requestUrl,\n Setting,\n TFile,\n WorkspaceLeaf,\n} from \"obsidian\";\nimport { spawn } from \"child_process\";\nimport { promises as fs } from \"fs\";\nimport { homedir } from \"os\";\nimport * as path from \"path\";\n\nconst VIEW_TYPE_AI_SIDEBAR = \"ai-sidebar-view\";\n\ntype AccessMode = \"read-only\" | \"confirm\" | \"full-access\";\ntype ProviderAuthType = \"api-key\" | \"oauth\" | \"cli\";\ntype ReasoningEffort = \"low\" | \"medium\" | \"high\";\n\ninterface AgentProvider {\n id: string;\n name: string;\n authType: ProviderAuthType;\n command: string;\n signInCommand?: string;\n setupUrl?: string;\n connectedAt?: number;\n apiBaseUrl?: string;\n apiKey?: string;\n model?: string;\n models?: string;\n reasoningEffort?: ReasoningEffort;\n authorizationUrl?: string;\n tokenUrl?: string;\n clientId?: string;\n scope?: string;\n accessToken?: string;\n refreshToken?: string;\n tokenExpiresAt?: number;\n}\n\ninterface AISidebarSettings {\n providers: AgentProvider[];\n defaultProviderId: string;\n defaultAccessMode: AccessMode;\n includeFolders: string;\n excludeFolders: string;\n maxContextChars: number;\n enableConversationMemory: boolean;\n maxMemoryMessages: number;\n rememberedMessages: ChatMessage[];\n}\n\ninterface VaultContextFile {\n path: string;\n content: string;\n reason: string;\n}\n\ninterface AgentSkill {\n name: string;\n path: string;\n content: string;\n}\n\ninterface SkillReference {\n name: string;\n path: string;\n source: \"vault\" | \"global\";\n}\n\ninterface VaultContext {\n activeFile?: VaultContextFile;\n selection?: string;\n linkedFiles: VaultContextFile[];\n backlinkFiles: VaultContextFile[];\n relevantFiles: VaultContextFile[];\n baseFiles: VaultContextFile[];\n selectedSkills: AgentSkill[];\n}\n\ninterface ChatMessage {\n role: \"user\" | \"assistant\" | \"system\";\n content: string;\n}\n\ninterface AgentRequest {\n prompt: string;\n accessMode: AccessMode;\n context: VaultContext;\n instructions: string;\n conversation: ChatMessage[];\n options: AgentRequestOptions;\n}\n\ninterface AgentRequestOptions {\n model?: string;\n reasoningEffort?: ReasoningEffort;\n memoryEnabled: boolean;\n}\n\ninterface ParsedPrompt {\n cleanPrompt: string;\n selectedSkillNames: string[];\n options: AgentRequestOptions;\n accessMode?: AccessMode;\n}\n\ntype VaultAction =\n | { type: \"create\"; path: string; content: string }\n | { type: \"edit\"; path: string; content: string }\n | { type: \"append\"; path: string; content: string }\n | { type: \"delete\"; path: string }\n | { type: \"rename\"; path: string; newPath: string };\n\ninterface ProviderPreset {\n id: string;\n name: string;\n description: string;\n provider: AgentProvider;\n}\n\nconst PROVIDER_PRESETS: ProviderPreset[] = [\n {\n id: \"codex\",\n name: \"Codex\",\n description: \"Sign in with Codex locally and use the Codex CLI agent.\",\n provider: {\n id: \"codex\",\n name: \"Codex\",\n authType: \"cli\",\n command: \"codex exec\",\n signInCommand: \"codex login\",\n models: \"gpt-5.4, gpt-5.4-mini, gpt-5.3-codex\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"claude\",\n name: \"Claude Code\",\n description: \"Sign in with your Claude subscription or Anthropic Console account.\",\n provider: {\n id: \"claude\",\n name: \"Claude Code\",\n authType: \"cli\",\n command: \"claude\",\n signInCommand: \"claude auth login\",\n setupUrl: \"https://code.claude.com/docs/en/cli-usage\",\n models: \"opus, sonnet, haiku\",\n },\n },\n {\n id: \"opencode\",\n name: \"opencode\",\n description: \"Connect opencode to any provider it supports, including subscription/API accounts.\",\n provider: {\n id: \"opencode\",\n name: \"opencode\",\n authType: \"cli\",\n command: \"opencode\",\n signInCommand: \"opencode auth login\",\n setupUrl: \"https://opencode.ai/docs/cli/\",\n models: \"anthropic/claude-sonnet-4-5, openai/gpt-5.4, google/gemini-2.5-pro\",\n },\n },\n {\n id: \"openai\",\n name: \"OpenAI API\",\n description: \"Use an OpenAI API key with the Responses API.\",\n provider: {\n id: \"openai\",\n name: \"OpenAI\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.openai.com/api-keys\",\n apiBaseUrl: \"https://api.openai.com/v1/responses\",\n model: \"gpt-4.1-mini\",\n models: \"gpt-4.1-mini, gpt-4.1, o4-mini\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n description: \"Use an Anthropic API key directly through an OpenAI-compatible proxy endpoint.\",\n provider: {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.anthropic.com/settings/keys\",\n apiBaseUrl: \"\",\n model: \"claude-sonnet-4-5\",\n models: \"claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5\",\n },\n },\n {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n description: \"Use Google Gemini CLI with its native sign-in and subscription/API setup.\",\n provider: {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n authType: \"cli\",\n command: \"gemini\",\n signInCommand: \"gemini\",\n setupUrl: \"https://google-gemini.github.io/gemini-cli/docs/\",\n models: \"gemini-2.5-pro, gemini-2.5-flash\",\n },\n },\n {\n id: \"openrouter\",\n name: \"OpenRouter\",\n description: \"Use one API key for many model providers through an OpenAI-compatible endpoint.\",\n provider: {\n id: \"openrouter\",\n name: \"OpenRouter\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://openrouter.ai/settings/keys\",\n apiBaseUrl: \"https://openrouter.ai/api/v1/chat/completions\",\n model: \"openai/gpt-4.1-mini\",\n models: \"openai/gpt-4.1-mini, anthropic/claude-sonnet-4.5, google/gemini-2.5-pro\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"deepseek\",\n name: \"DeepSeek\",\n description: \"Use DeepSeek with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.deepseek.com/api_keys\",\n apiBaseUrl: \"https://api.deepseek.com/v1/chat/completions\",\n model: \"deepseek-chat\",\n models: \"deepseek-chat, deepseek-reasoner\",\n },\n },\n {\n id: \"mistral\",\n name: \"Mistral\",\n description: \"Use Mistral models with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"mistral\",\n name: \"Mistral\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.mistral.ai/api-keys\",\n apiBaseUrl: \"https://api.mistral.ai/v1/chat/completions\",\n model: \"mistral-large-latest\",\n models: \"mistral-large-latest, codestral-latest, ministral-8b-latest\",\n },\n },\n {\n id: \"aider\",\n name: \"Aider\",\n description: \"Use Aider with keys from OpenAI, Anthropic, Gemini, OpenRouter, DeepSeek, and more.\",\n provider: {\n id: \"aider\",\n name: \"Aider\",\n authType: \"cli\",\n command: \"aider\",\n signInCommand: \"aider\",\n setupUrl: \"https://aider.chat/docs/config/api-keys.html\",\n models: \"sonnet, opus, gpt-4.1, o4-mini, gemini/gemini-2.5-pro\",\n },\n },\n];\n\nconst DEFAULT_SETTINGS: AISidebarSettings = {\n providers: [\n cloneProvider(PROVIDER_PRESETS[0].provider),\n cloneProvider(PROVIDER_PRESETS[3].provider),\n ],\n defaultProviderId: \"codex\",\n defaultAccessMode: \"confirm\",\n includeFolders: \"\",\n excludeFolders: \".obsidian, node_modules, .git\",\n maxContextChars: 45000,\n enableConversationMemory: true,\n maxMemoryMessages: 30,\n rememberedMessages: [],\n};\n\nexport default class AISidebarPlugin extends Plugin {\n settings: AISidebarSettings;\n\n async onload() {\n await this.loadSettings();\n\n this.registerView(\n VIEW_TYPE_AI_SIDEBAR,\n (leaf) => new AISidebarView(leaf, this),\n );\n\n this.addRibbonIcon(\"sparkles\", \"Toggle AI Sidebar\", () => {\n void this.toggleSidebar();\n });\n\n this.addCommand({\n id: \"toggle-ai-sidebar\",\n name: \"Toggle AI Sidebar\",\n hotkeys: [{ modifiers: [\"Mod\", \"Shift\"], key: \"A\" }],\n callback: () => {\n void this.toggleSidebar();\n },\n });\n\n this.addSettingTab(new AISidebarSettingTab(this.app, this));\n }\n\n onunload() {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n }\n\n async toggleSidebar() {\n const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AI_SIDEBAR).first();\n if (existingLeaf) {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n return;\n }\n\n const leaf = this.app.workspace.getRightLeaf(false);\n if (!leaf) {\n new Notice(\"Could not open the AI sidebar.\");\n return;\n }\n\n await leaf.setViewState({ type: VIEW_TYPE_AI_SIDEBAR, active: true });\n this.app.workspace.revealLeaf(leaf);\n }\n\n async loadSettings() {\n const loaded = await this.loadData();\n this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);\n this.settings.providers = normalizeProviders(this.settings.providers);\n this.settings.rememberedMessages = Array.isArray(this.settings.rememberedMessages)\n ? this.settings.rememberedMessages\n : [];\n }\n\n async saveSettings() {\n await this.saveData(this.settings);\n }\n\n getProvider(id: string): AgentProvider | undefined {\n return this.settings.providers.find((provider) => provider.id === id);\n }\n\n async rememberMessages(messages: ChatMessage[]) {\n if (!this.settings.enableConversationMemory) return;\n this.settings.rememberedMessages = messages.slice(-this.settings.maxMemoryMessages);\n await this.saveSettings();\n }\n\n async clearMemory() {\n this.settings.rememberedMessages = [];\n await this.saveSettings();\n }\n\n async upsertProviderFromPreset(preset: ProviderPreset) {\n const provider = cloneProvider(preset.provider);\n const existingIndex = this.settings.providers.findIndex((item) => item.id === provider.id);\n if (existingIndex >= 0) {\n this.settings.providers[existingIndex] = {\n ...provider,\n apiKey: this.settings.providers[existingIndex].apiKey,\n accessToken: this.settings.providers[existingIndex].accessToken,\n refreshToken: this.settings.providers[existingIndex].refreshToken,\n };\n } else {\n this.settings.providers.push(provider);\n }\n this.settings.defaultProviderId = provider.id;\n await this.saveSettings();\n }\n}\n\nclass AISidebarView extends ItemView {\n private plugin: AISidebarPlugin;\n private messages: ChatMessage[] = [];\n private providerId: string;\n private accessMode: AccessMode;\n private threadEl: HTMLElement;\n private textareaEl: HTMLTextAreaElement;\n private contextEl: HTMLElement;\n private slashEl: HTMLElement;\n private modelBadgeEl: HTMLElement;\n private activeContextPath = \"\";\n private refreshTimer: number | undefined;\n private sendButton: ButtonComponent;\n private isRunning = false;\n\n constructor(leaf: WorkspaceLeaf, plugin: AISidebarPlugin) {\n super(leaf);\n this.plugin = plugin;\n this.messages = plugin.settings.enableConversationMemory\n ? [...plugin.settings.rememberedMessages]\n : [];\n this.providerId = plugin.settings.defaultProviderId;\n this.accessMode = plugin.settings.defaultAccessMode;\n }\n\n getViewType(): string {\n return VIEW_TYPE_AI_SIDEBAR;\n }\n\n getDisplayText(): string {\n return \"AI Sidebar\";\n }\n\n getIcon(): string {\n return \"sparkles\";\n }\n\n async onOpen() {\n this.render();\n this.registerEvent(\n this.app.workspace.on(\"active-leaf-change\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.workspace.on(\"file-open\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.vault.on(\"modify\", (file) => {\n if (file instanceof TFile && file.path === this.activeContextPath) {\n this.scheduleContextRefresh();\n }\n }),\n );\n await this.refreshContextPreview();\n }\n\n async onClose() {\n this.contentEl.empty();\n }\n\n private render() {\n this.contentEl.empty();\n this.contentEl.addClass(\"ai-sidebar\");\n\n const header = this.contentEl.createDiv(\"ai-sidebar__header\");\n header.createDiv({ cls: \"ai-sidebar__title\", text: \"AI Sidebar\" });\n header.createDiv({ cls: \"ai-sidebar__subtitle\", text: \"Vault-aware agents\" });\n\n const controls = this.contentEl.createDiv(\"ai-sidebar__controls\");\n new DropdownComponent(controls)\n .addOptions(this.providerOptions())\n .setValue(this.providerId)\n .onChange((value) => {\n this.providerId = value;\n this.updateModelBadge();\n });\n\n new DropdownComponent(controls)\n .addOptions({\n \"read-only\": \"Read only\",\n confirm: \"Confirm actions\",\n \"full-access\": \"Full access\",\n })\n .setValue(this.accessMode)\n .onChange((value: AccessMode) => {\n this.accessMode = value;\n void this.refreshContextPreview();\n });\n\n this.contextEl = this.contentEl.createDiv(\"ai-sidebar__context\");\n\n this.threadEl = this.contentEl.createDiv(\"ai-sidebar__thread\");\n this.renderMessages();\n\n const composer = this.contentEl.createDiv(\"ai-sidebar__composer\");\n this.textareaEl = composer.createEl(\"textarea\", {\n cls: \"ai-sidebar__input\",\n attr: {\n placeholder: \"Ask about your vault, draft a note, or request an edit...\",\n rows: \"4\",\n },\n });\n\n this.textareaEl.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Escape\" && !this.slashEl.hasClass(\"is-hidden\")) {\n event.preventDefault();\n this.hideSlashCommands();\n return;\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n void this.sendPrompt();\n }\n });\n this.textareaEl.addEventListener(\"input\", () => {\n void this.updateSlashCommands();\n this.updateModelBadge();\n });\n\n this.slashEl = composer.createDiv(\"ai-sidebar__slash is-hidden\");\n\n const actions = composer.createDiv(\"ai-sidebar__composer-actions\");\n new ButtonComponent(actions)\n .setButtonText(\"Refresh context\")\n .onClick(() => {\n void this.refreshContextPreview();\n });\n new ButtonComponent(actions)\n .setButtonText(\"Clear\")\n .onClick(() => {\n void this.clearConversation();\n });\n\n this.modelBadgeEl = actions.createDiv(\"ai-sidebar__model-badge\");\n this.updateModelBadge();\n\n this.sendButton = new ButtonComponent(actions)\n .setCta()\n .setButtonText(\"Send\")\n .onClick(() => {\n void this.sendPrompt();\n });\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderMessages() {\n this.threadEl.empty();\n\n if (this.messages.length === 0) {\n const empty = this.threadEl.createDiv(\"ai-sidebar__empty\");\n empty.createDiv({ text: \"Ask a local agent to work with the notes in this vault.\" });\n empty.createDiv({ text: \"Use confirm mode when you want to review file edits first.\" });\n return;\n }\n\n for (const message of this.messages) {\n const messageEl = this.threadEl.createDiv(`ai-sidebar__message ai-sidebar__message--${message.role}`);\n messageEl.createDiv({ cls: \"ai-sidebar__message-role\", text: message.role });\n messageEl.createEl(\"pre\", { text: message.content });\n }\n\n if (this.isRunning) {\n const thinkingEl = this.threadEl.createDiv(\"ai-sidebar__message ai-sidebar__message--thinking\");\n thinkingEl.createDiv({ cls: \"ai-sidebar__message-role\", text: \"assistant\" });\n const row = thinkingEl.createDiv(\"ai-sidebar__typing\");\n row.createSpan({ text: \"Thinking\" });\n const dots = row.createSpan(\"ai-sidebar__typing-dots\");\n dots.createSpan();\n dots.createSpan();\n dots.createSpan();\n }\n\n this.threadEl.scrollTo({ top: this.threadEl.scrollHeight });\n }\n\n private async refreshContextPreview(prompt = \"\") {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n prompt,\n this.extractSelectedSkillNames(prompt),\n );\n this.activeContextPath = context.activeFile?.path ?? \"\";\n this.contextEl.empty();\n this.contextEl.createDiv({\n cls: \"ai-sidebar__context-title\",\n text: `Context preview · ${this.accessModeLabel()}`,\n });\n\n const items = [\n context.activeFile ? `Active: ${context.activeFile.path}` : \"No active note\",\n context.selection ? \"Selection included\" : \"No selection\",\n `${context.linkedFiles.length} linked`,\n `${context.backlinkFiles.length} backlinks`,\n `${context.relevantFiles.length} relevant`,\n `${context.baseFiles.length} bases`,\n `${context.selectedSkills.length} skills`,\n ];\n\n const list = this.contextEl.createEl(\"ul\");\n for (const item of items) {\n list.createEl(\"li\", { text: item });\n }\n }\n\n private scheduleContextRefresh() {\n window.clearTimeout(this.refreshTimer);\n this.refreshTimer = window.setTimeout(() => {\n void this.refreshContextPreview(this.textareaEl?.value ?? \"\");\n }, 150);\n }\n\n private accessModeLabel(): string {\n if (this.accessMode === \"read-only\") return \"read only\";\n if (this.accessMode === \"full-access\") return \"full access\";\n return \"confirm actions\";\n }\n\n private async sendPrompt() {\n const rawPrompt = this.textareaEl.value.trim();\n if (!rawPrompt || this.isRunning) return;\n\n const provider = this.plugin.getProvider(this.providerId);\n if (!provider) {\n new Notice(\"Choose a provider in AI Sidebar settings.\");\n return;\n }\n\n if (!isProviderReady(provider)) {\n new Notice(\"Connect or configure this provider in AI Sidebar settings first.\");\n return;\n }\n\n const parsed = this.parsePromptControls(rawPrompt, provider);\n if (this.isSlashOnlyPrompt(rawPrompt, parsed)) {\n await this.applySlashControls(provider, parsed);\n this.textareaEl.value = \"\";\n this.updateModelBadge();\n await this.refreshContextPreview();\n return;\n }\n\n this.isRunning = true;\n this.sendButton.setButtonText(\"Running...\");\n this.sendButton.setDisabled(true);\n this.textareaEl.value = \"\";\n const effectiveAccessMode = parsed.accessMode ?? this.accessMode;\n this.messages.push({ role: \"user\", content: rawPrompt });\n this.renderMessages();\n\n try {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n parsed.cleanPrompt,\n parsed.selectedSkillNames,\n );\n await this.refreshContextPreview(rawPrompt);\n const request: AgentRequest = {\n prompt: parsed.cleanPrompt,\n accessMode: effectiveAccessMode,\n context,\n instructions: buildAgentInstructions(effectiveAccessMode),\n conversation: this.plugin.settings.enableConversationMemory ? this.messages.slice(0, -1) : [],\n options: parsed.options,\n };\n\n const response = await runProvider(provider, request, this.app);\n const actionResult = await this.handleActions(response);\n const assistantContent = actionResult ? `${response}\\n\\n${actionResult}` : response;\n this.messages.push({ role: \"assistant\", content: assistantContent.trim() || \"No response.\" });\n await this.plugin.rememberMessages(this.messages);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n this.messages.push({ role: \"system\", content: `Provider failed: ${message}` });\n await this.plugin.rememberMessages(this.messages);\n new Notice(\"AI provider failed. Check the sidebar for details.\");\n } finally {\n this.isRunning = false;\n this.sendButton.setButtonText(\"Send\");\n this.sendButton.setDisabled(false);\n this.updateModelBadge();\n this.renderMessages();\n }\n }\n\n private async clearConversation() {\n this.messages = [];\n await this.plugin.clearMemory();\n this.renderMessages();\n new Notice(\"AI Sidebar memory cleared.\");\n }\n\n private async handleActions(response: string): Promise<string> {\n const actions = extractVaultActions(response);\n if (actions.length === 0) return \"\";\n\n if (this.accessMode === \"read-only\") {\n return \"Vault actions were proposed, but read-only mode blocked all writes.\";\n }\n\n if (this.accessMode === \"confirm\") {\n const approved = await confirmVaultActions(this.app, actions);\n if (!approved) return \"Vault actions were not applied.\";\n }\n\n const results: string[] = [];\n for (const action of actions) {\n try {\n await applyVaultAction(this.app, action);\n results.push(`Applied ${action.type}: ${action.path}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n results.push(`Failed ${action.type}: ${action.path} · ${message}`);\n }\n }\n return results.join(\"\\n\");\n }\n\n private async updateSlashCommands() {\n const query = this.currentSlashQuery();\n if (query === null) {\n this.hideSlashCommands();\n return;\n }\n\n const provider = this.plugin.getProvider(this.providerId);\n const commands = provider ? nativeSlashCommands(provider, query, this.accessMode) : [];\n const skills = await listAgentSkills(this.plugin.app, query);\n this.slashEl.empty();\n\n if (commands.length === 0 && skills.length === 0) {\n this.hideSlashCommands();\n return;\n }\n\n this.slashEl.removeClass(\"is-hidden\");\n if (skills.length > 0) {\n this.slashEl.createDiv({ cls: \"ai-sidebar__slash-section\", text: \"Skills\" });\n }\n for (const skill of skills.slice(0, 12)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: `/${skill.name}` });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: skill.path });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(`/${skill.name}`);\n });\n }\n if (commands.length > 0) {\n this.slashEl.createDiv({ cls: \"ai-sidebar__slash-section\", text: \"Controls\" });\n }\n for (const command of commands.slice(0, 8)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: command.label });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: command.description });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(command.insert);\n });\n }\n }\n\n private hideSlashCommands() {\n this.slashEl.empty();\n this.slashEl.addClass(\"is-hidden\");\n }\n\n private currentSlashQuery(): string | null {\n const cursor = this.textareaEl.selectionStart;\n const beforeCursor = this.textareaEl.value.slice(0, cursor);\n const match = beforeCursor.match(/(?:^|\\s)\\/([A-Za-z0-9_.:-]*)$/);\n return match ? match[1].toLowerCase() : null;\n }\n\n private insertSlashToken(token: string) {\n const cursor = this.textareaEl.selectionStart;\n const value = this.textareaEl.value;\n const beforeCursor = value.slice(0, cursor);\n const afterCursor = value.slice(cursor);\n const replaced = beforeCursor.replace(/(?:^|\\s)\\/([A-Za-z0-9_.:-]*)$/, (match) => {\n const prefix = match.startsWith(\" \") ? \" \" : \"\";\n return `${prefix}${token} `;\n });\n this.textareaEl.value = `${replaced}${afterCursor}`;\n this.textareaEl.focus();\n this.textareaEl.selectionStart = replaced.length;\n this.textareaEl.selectionEnd = replaced.length;\n this.hideSlashCommands();\n this.scheduleContextRefresh();\n }\n\n private extractSelectedSkillNames(prompt: string): string[] {\n const native = new Set([\"model\", \"reasoning\", \"access\", \"memory\"]);\n return Array.from(prompt.matchAll(/(?:^|\\s)\\/([A-Za-z0-9_.-]+)/g), (match) => match[1])\n .filter((name) => !native.has(name.split(\":\")[0].toLowerCase()));\n }\n\n private parsePromptControls(prompt: string, provider: AgentProvider): ParsedPrompt {\n const options: AgentRequestOptions = {\n model: provider.model,\n reasoningEffort: provider.reasoningEffort,\n memoryEnabled: this.plugin.settings.enableConversationMemory,\n };\n let accessMode: AccessMode | undefined;\n\n const cleanPrompt = prompt.replace(/(?:^|\\s)\\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match, key, value) => {\n const normalizedKey = String(key).toLowerCase();\n const normalizedValue = String(value).toLowerCase();\n if (normalizedKey === \"model\") {\n options.model = value;\n } else if (normalizedKey === \"reasoning\" && isReasoningEffort(normalizedValue)) {\n options.reasoningEffort = normalizedValue;\n } else if (normalizedKey === \"access\" && isAccessMode(normalizedValue)) {\n accessMode = normalizedValue;\n } else if (normalizedKey === \"memory\") {\n options.memoryEnabled = normalizedValue === \"on\";\n }\n return match.startsWith(\" \") ? \" \" : \"\";\n }).trim();\n\n return {\n cleanPrompt,\n selectedSkillNames: this.extractSelectedSkillNames(prompt),\n options,\n accessMode,\n };\n }\n\n private isSlashOnlyPrompt(prompt: string, parsed: ParsedPrompt): boolean {\n return parsed.cleanPrompt.length === 0 && this.extractSelectedSkillNames(prompt).length === 0;\n }\n\n private async applySlashControls(provider: AgentProvider, parsed: ParsedPrompt) {\n const changes: string[] = [];\n if (parsed.options.model && parsed.options.model !== provider.model) {\n provider.model = parsed.options.model;\n changes.push(`model ${parsed.options.model}`);\n }\n if (parsed.options.reasoningEffort && parsed.options.reasoningEffort !== provider.reasoningEffort) {\n provider.reasoningEffort = parsed.options.reasoningEffort;\n changes.push(`reasoning ${parsed.options.reasoningEffort}`);\n }\n if (parsed.accessMode && parsed.accessMode !== this.accessMode) {\n this.accessMode = parsed.accessMode;\n this.plugin.settings.defaultAccessMode = parsed.accessMode;\n changes.push(`access ${this.accessModeLabel()}`);\n }\n if (parsed.options.memoryEnabled !== this.plugin.settings.enableConversationMemory) {\n this.plugin.settings.enableConversationMemory = parsed.options.memoryEnabled;\n changes.push(`memory ${parsed.options.memoryEnabled ? \"on\" : \"off\"}`);\n }\n await this.plugin.saveSettings();\n new Notice(changes.length > 0 ? `Updated ${changes.join(\", \")}.` : \"Slash command applied.\");\n }\n\n private updateModelBadge() {\n if (!this.modelBadgeEl) return;\n const provider = this.plugin.getProvider(this.providerId);\n const override = this.textareaEl?.value.match(/(?:^|\\s)\\/model:([A-Za-z0-9_.:/-]+)/i)?.[1];\n const model = override || provider?.model || parseProviderModels(provider ?? DEFAULT_SETTINGS.providers[0]).first() || \"default\";\n this.modelBadgeEl.setText(model);\n this.modelBadgeEl.toggleClass(\"is-override\", Boolean(override));\n this.modelBadgeEl.setAttr(\"aria-label\", override ? `Model override: ${model}` : `Model: ${model}`);\n }\n}\n\nclass AISidebarSettingTab extends PluginSettingTab {\n plugin: AISidebarPlugin;\n\n constructor(app: App, plugin: AISidebarPlugin) {\n super(app, plugin);\n this.plugin = plugin;\n }\n\n display(): void {\n const { containerEl } = this;\n containerEl.empty();\n containerEl.createEl(\"h2\", { text: \"AI Sidebar\" });\n\n new Setting(containerEl)\n .setName(\"Default provider\")\n .setDesc(\"The AI provider used when the sidebar opens.\")\n .addDropdown((dropdown) => {\n dropdown.addOptions(this.providerOptions());\n dropdown.setValue(this.plugin.settings.defaultProviderId);\n dropdown.onChange(async (value) => {\n this.plugin.settings.defaultProviderId = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Default vault access\")\n .setDesc(\"How much permission the agent has when it proposes changes.\")\n .addDropdown((dropdown) => {\n dropdown\n .addOption(\"read-only\", \"Read only\")\n .addOption(\"confirm\", \"Confirm actions\")\n .addOption(\"full-access\", \"Full access\")\n .setValue(this.plugin.settings.defaultAccessMode)\n .onChange(async (value: AccessMode) => {\n this.plugin.settings.defaultAccessMode = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Include folders\")\n .setDesc(\"Comma-separated folder prefixes to include. Leave blank for the whole vault.\")\n .addText((text) => {\n text\n .setPlaceholder(\"Projects, Daily Notes\")\n .setValue(this.plugin.settings.includeFolders)\n .onChange(async (value) => {\n this.plugin.settings.includeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Exclude folders\")\n .setDesc(\"Comma-separated folder prefixes that should never be sent to an agent.\")\n .addText((text) => {\n text\n .setValue(this.plugin.settings.excludeFolders)\n .onChange(async (value) => {\n this.plugin.settings.excludeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Max context characters\")\n .setDesc(\"Upper bound for note text sent to the agent.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxContextChars))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed > 1000) {\n this.plugin.settings.maxContextChars = parsed;\n await this.plugin.saveSettings();\n }\n });\n });\n\n new Setting(containerEl)\n .setName(\"Conversation memory\")\n .setDesc(\"Remember previous sidebar messages after closing and reopening Obsidian.\")\n .addToggle((toggle) => {\n toggle\n .setValue(this.plugin.settings.enableConversationMemory)\n .onChange(async (value) => {\n this.plugin.settings.enableConversationMemory = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Memory length\")\n .setDesc(\"Maximum number of recent sidebar messages to remember.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxMemoryMessages))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed >= 2) {\n this.plugin.settings.maxMemoryMessages = parsed;\n await this.plugin.saveSettings();\n }\n });\n })\n .addButton((button) => {\n button.setButtonText(\"Clear memory\").onClick(async () => {\n await this.plugin.clearMemory();\n new Notice(\"AI Sidebar memory cleared.\");\n });\n });\n\n containerEl.createEl(\"h3\", { text: \"Connect a Provider\" });\n this.renderProviderPresets(containerEl);\n\n containerEl.createEl(\"h3\", { text: \"Connected Providers\" });\n for (const provider of this.plugin.settings.providers) {\n this.renderProviderSetting(containerEl, provider);\n }\n\n new Setting(containerEl)\n .setName(\"Advanced provider\")\n .setDesc(\"Create a custom API, OAuth, or local CLI provider.\")\n .addButton((button) => {\n button.setButtonText(\"Add custom\").onClick(async () => {\n const id = `provider-${Date.now()}`;\n this.plugin.settings.providers.push({ id, name: \"New provider\", authType: \"oauth\", command: \"\" });\n await this.plugin.saveSettings();\n this.display();\n });\n });\n }\n\n private renderProviderPresets(containerEl: HTMLElement) {\n const grid = containerEl.createDiv(\"ai-sidebar-provider-grid\");\n for (const preset of PROVIDER_PRESETS) {\n const provider = this.plugin.getProvider(preset.provider.id);\n const card = grid.createDiv(\"ai-sidebar-provider-card\");\n card.createDiv({ cls: \"ai-sidebar-provider-card__name\", text: preset.name });\n card.createDiv({ cls: \"ai-sidebar-provider-card__desc\", text: preset.description });\n const status = provider ? providerConnectionLabel(provider) : \"Not added\";\n card.createDiv({ cls: \"ai-sidebar-provider-card__status\", text: status });\n\n const actions = card.createDiv(\"ai-sidebar-provider-card__actions\");\n new ButtonComponent(actions)\n .setButtonText(provider ? \"Use\" : \"Add\")\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n this.display();\n });\n\n new ButtonComponent(actions)\n .setCta()\n .setButtonText(providerConnectionActionText(provider ?? preset.provider))\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n const connectedProvider = this.plugin.getProvider(preset.provider.id) ?? preset.provider;\n if (await startProviderSignIn(connectedProvider)) {\n connectedProvider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n }\n this.display();\n });\n }\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderProviderSetting(containerEl: HTMLElement, provider: AgentProvider) {\n const wrapper = containerEl.createDiv(\"ai-sidebar-settings-provider\");\n const header = wrapper.createDiv(\"ai-sidebar-settings-provider__header\");\n const title = header.createDiv(\"ai-sidebar-settings-provider__title\");\n title.createDiv({ cls: \"ai-sidebar-settings-provider__name\", text: provider.name || provider.id });\n title.createDiv({ cls: \"ai-sidebar-settings-provider__desc\", text: this.providerDescription(provider) });\n header.createSpan({\n cls: `ai-sidebar-settings-provider__badge ${isProviderConnected(provider) ? \"is-connected\" : \"\"}`,\n text: providerConnectionLabel(provider),\n });\n\n const actions = wrapper.createDiv(\"ai-sidebar-settings-provider__actions\");\n new ButtonComponent(actions)\n .setButtonText(this.plugin.settings.defaultProviderId === provider.id ? \"Default\" : \"Use\")\n .setDisabled(this.plugin.settings.defaultProviderId === provider.id)\n .onClick(async () => {\n this.plugin.settings.defaultProviderId = provider.id;\n await this.plugin.saveSettings();\n this.display();\n });\n this.configureConnectButton(new ButtonComponent(actions), provider);\n new ButtonComponent(actions)\n .setIcon(\"trash\")\n .setTooltip(\"Remove provider\")\n .onClick(async () => {\n this.plugin.settings.providers = this.plugin.settings.providers.filter((item) => item !== provider);\n if (this.plugin.settings.defaultProviderId === provider.id) {\n this.plugin.settings.defaultProviderId = this.plugin.settings.providers.first()?.id ?? \"\";\n }\n await this.plugin.saveSettings();\n this.display();\n });\n\n const fields = wrapper.createDiv(\"ai-sidebar-settings-provider__fields\");\n this.renderTextField(fields, \"Name\", provider.name, async (value) => {\n provider.name = value;\n await this.plugin.saveSettings();\n this.display();\n });\n\n if (provider.authType === \"cli\") {\n this.renderTextField(fields, \"Command\", provider.command, async (value) => {\n provider.command = value;\n await this.plugin.saveSettings();\n });\n if (provider.signInCommand) {\n this.renderTextField(fields, \"Sign-in command\", provider.signInCommand, async (value) => {\n provider.signInCommand = value;\n provider.connectedAt = undefined;\n await this.plugin.saveSettings();\n });\n }\n }\n\n if (provider.authType === \"api-key\") {\n this.renderTextField(fields, \"API key\", provider.apiKey ?? \"\", async (value) => {\n provider.apiKey = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n this.renderTextField(fields, \"Endpoint\", provider.apiBaseUrl ?? \"\", async (value) => {\n provider.apiBaseUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Default model\", provider.model ?? \"\", async (value) => {\n provider.model = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Slash models\", provider.models ?? \"\", async (value) => {\n provider.models = value;\n await this.plugin.saveSettings();\n });\n }\n\n if (provider.authType === \"oauth\") {\n this.renderTextField(fields, \"Authorization URL\", provider.authorizationUrl ?? \"\", async (value) => {\n provider.authorizationUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Client ID\", provider.clientId ?? \"\", async (value) => {\n provider.clientId = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Access token\", provider.accessToken ?? \"\", async (value) => {\n provider.accessToken = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n }\n }\n\n private renderTextField(\n containerEl: HTMLElement,\n label: string,\n value: string,\n onChange: (value: string) => Promise<void>,\n secret = false,\n ) {\n const field = containerEl.createDiv(\"ai-sidebar-settings-field\");\n field.createDiv({ cls: \"ai-sidebar-settings-field__label\", text: label });\n const input = field.createEl(\"input\", {\n cls: \"ai-sidebar-settings-field__input\",\n attr: { type: secret ? \"password\" : \"text\" },\n });\n input.value = value;\n input.addEventListener(\"change\", () => {\n void onChange(input.value);\n });\n }\n\n private providerDescription(provider: AgentProvider): string {\n if (provider.authType === \"api-key\") {\n return provider.apiKey ? \"Connected with API key.\" : \"Add an API key to connect.\";\n }\n if (provider.authType === \"oauth\") {\n return provider.accessToken ? \"Connected with OAuth.\" : \"Connect with OAuth when the provider exposes a desktop OAuth app.\";\n }\n return provider.command ? `Runs local command: ${provider.command}` : \"Add the local CLI command.\";\n }\n\n private configureConnectButton(button: ButtonComponent, provider: AgentProvider) {\n button\n .setButtonText(providerConnectionActionText(provider))\n .onClick(async () => {\n if (await startProviderSignIn(provider)) {\n provider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n this.display();\n }\n });\n }\n}\n\nclass ConfirmActionsModal extends Modal {\n private actions: VaultAction[];\n private resolve: (approved: boolean) => void;\n\n constructor(app: App, actions: VaultAction[], resolve: (approved: boolean) => void) {\n super(app);\n this.actions = actions;\n this.resolve = resolve;\n }\n\n onOpen() {\n const { contentEl } = this;\n contentEl.empty();\n contentEl.createEl(\"h2\", { text: \"Apply AI vault actions?\" });\n contentEl.createEl(\"p\", { text: \"Review these proposed file changes before they are applied.\" });\n\n for (const action of this.actions) {\n const actionEl = contentEl.createDiv(\"ai-sidebar-action-preview\");\n actionEl.createEl(\"strong\", { text: `${action.type}: ${action.path}` });\n if (action.type === \"rename\") {\n actionEl.createDiv({ text: `New path: ${action.newPath}` });\n }\n if (\"content\" in action) {\n actionEl.createEl(\"pre\", { text: action.content.slice(0, 1200) });\n }\n }\n\n new Setting(contentEl)\n .addButton((button) => {\n button.setButtonText(\"Cancel\").onClick(() => {\n this.resolve(false);\n this.close();\n });\n })\n .addButton((button) => {\n button.setCta().setButtonText(\"Apply\").onClick(() => {\n this.resolve(true);\n this.close();\n });\n });\n }\n\n onClose() {\n this.contentEl.empty();\n }\n}\n\nasync function collectVaultContext(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n selectedSkillNames: string[] = [],\n): Promise<VaultContext> {\n const activeView = app.workspace.getActiveViewOfType(MarkdownView);\n const activeFile = activeView?.file ?? app.workspace.getActiveFile();\n const selection = activeView?.editor.getSelection() || undefined;\n const budget = new ContextBudget(settings.maxContextChars);\n\n const context: VaultContext = {\n selection,\n linkedFiles: [],\n backlinkFiles: [],\n relevantFiles: [],\n baseFiles: [],\n selectedSkills: [],\n };\n\n const activeContext = activeFile ? await readContextFile(app, activeFile, \"active note\", budget) : undefined;\n if (activeContext) context.activeFile = activeContext;\n\n const linked = activeFile ? getLinkedFiles(app, activeFile) : [];\n for (const file of linked) {\n const item = await readContextFile(app, file, \"linked note\", budget);\n if (item) context.linkedFiles.push(item);\n }\n\n const backlinks = activeFile ? getBacklinkFiles(app, activeFile) : [];\n for (const file of backlinks) {\n const item = await readContextFile(app, file, \"backlink\", budget);\n if (item) context.backlinkFiles.push(item);\n }\n\n const baseFiles = app.vault\n .getFiles()\n .filter((file) => file.extension === \"base\" && shouldIncludeFile(file, settings))\n .slice(0, 5);\n for (const file of baseFiles) {\n const item = await readContextFile(app, file, \"base file\", budget);\n if (item) context.baseFiles.push(item);\n }\n\n const relatedFiles = await findRelevantFiles(app, settings, prompt, activeFile, budget.remaining());\n for (const file of relatedFiles) {\n const item = await readContextFile(app, file, \"relevant match\", budget);\n if (item) context.relevantFiles.push(item);\n }\n\n context.selectedSkills = await readSelectedSkills(app, selectedSkillNames, budget);\n\n return context;\n}\n\nasync function readContextFile(\n app: App,\n file: TFile,\n reason: string,\n budget: ContextBudget,\n): Promise<VaultContextFile | undefined> {\n if (!budget.hasRoom()) return undefined;\n const content = await app.vault.cachedRead(file);\n const clipped = budget.take(content);\n return { path: file.path, content: clipped, reason };\n}\n\nfunction getLinkedFiles(app: App, activeFile: TFile): TFile[] {\n const cache = app.metadataCache.getFileCache(activeFile);\n const links = cache?.links ?? [];\n const files: TFile[] = [];\n\n for (const link of links) {\n const linkedFile = app.metadataCache.getFirstLinkpathDest(link.link, activeFile.path);\n if (linkedFile) files.push(linkedFile);\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nfunction getBacklinkFiles(app: App, activeFile: TFile): TFile[] {\n const resolvedLinks = app.metadataCache.resolvedLinks;\n const files: TFile[] = [];\n\n for (const [sourcePath, targets] of Object.entries(resolvedLinks)) {\n if (targets[activeFile.path]) {\n const file = app.vault.getAbstractFileByPath(sourcePath);\n if (file instanceof TFile) files.push(file);\n }\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nasync function findRelevantFiles(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n activeFile: TFile | null,\n maxChars: number,\n): Promise<TFile[]> {\n if (!prompt.trim() || maxChars <= 0) return [];\n const terms = prompt\n .toLowerCase()\n .split(/[^a-z0-9/_-]+/i)\n .filter((term) => term.length > 2)\n .slice(0, 16);\n if (terms.length === 0) return [];\n\n const scored: Array<{ file: TFile; score: number }> = [];\n const files = app.vault\n .getFiles()\n .filter((file) => file !== activeFile && shouldIncludeFile(file, settings) && isReadableContextFile(file));\n\n for (const file of files) {\n const content = (await app.vault.cachedRead(file)).toLowerCase();\n const path = file.path.toLowerCase();\n let score = 0;\n for (const term of terms) {\n if (path.includes(term)) score += 4;\n const matches = content.match(new RegExp(escapeRegExp(term), \"g\"));\n score += matches ? Math.min(matches.length, 8) : 0;\n }\n if (score > 0) scored.push({ file, score });\n }\n\n return scored\n .sort((a, b) => b.score - a.score)\n .slice(0, 10)\n .map((item) => item.file);\n}\n\nfunction shouldIncludeFile(file: TFile, settings: AISidebarSettings): boolean {\n const includes = parseFolderList(settings.includeFolders);\n const excludes = parseFolderList(settings.excludeFolders);\n if (includes.length > 0 && !includes.some((folder) => file.path.startsWith(folder))) return false;\n if (excludes.some((folder) => file.path.startsWith(folder))) return false;\n return isReadableContextFile(file);\n}\n\nfunction isReadableContextFile(file: TFile): boolean {\n return [\"md\", \"canvas\", \"base\", \"json\", \"txt\"].includes(file.extension);\n}\n\nfunction nativeSlashCommands(\n provider: AgentProvider,\n query: string,\n currentAccessMode: AccessMode,\n): Array<{ label: string; description: string; insert: string }> {\n const commands: Array<{ label: string; description: string; insert: string }> = [];\n const modelChoices = parseProviderModels(provider);\n\n for (const model of modelChoices) {\n commands.push({\n label: `/model:${model}`,\n description: `Use ${model} for this request`,\n insert: `/model:${model}`,\n });\n }\n\n if (provider.authType === \"api-key\" || provider.id.includes(\"codex\") || provider.id.includes(\"openai\")) {\n for (const effort of [\"low\", \"medium\", \"high\"] as ReasoningEffort[]) {\n commands.push({\n label: `/reasoning:${effort}`,\n description: `Set reasoning effort to ${effort}`,\n insert: `/reasoning:${effort}`,\n });\n }\n }\n\n for (const mode of [\"read-only\", \"confirm\", \"full-access\"] as AccessMode[]) {\n commands.push({\n label: `/access:${mode}`,\n description: mode === currentAccessMode ? \"Current vault access mode\" : `Use ${mode} for this request`,\n insert: `/access:${mode}`,\n });\n }\n\n commands.push(\n { label: \"/memory:on\", description: \"Include remembered chat history for this request\", insert: \"/memory:on\" },\n { label: \"/memory:off\", description: \"Do not include remembered chat history for this request\", insert: \"/memory:off\" },\n );\n\n const normalizedQuery = query.toLowerCase();\n return commands.filter((command) => command.label.slice(1).toLowerCase().includes(normalizedQuery));\n}\n\nfunction parseProviderModels(provider: AgentProvider): string[] {\n const configured = provider.models\n ?.split(\",\")\n .map((model) => model.trim())\n .filter(Boolean) ?? [];\n const models = configured.length > 0 ? configured : [provider.model].filter((model): model is string => Boolean(model));\n return Array.from(new Set(models)).slice(0, 12);\n}\n\nasync function listAgentSkills(app: App, query = \"\"): Promise<SkillReference[]> {\n const normalizedQuery = query.toLowerCase();\n const vaultSkills = app.vault\n .getFiles()\n .filter((file) => isSkillFile(file))\n .map((file) => ({\n name: skillNameFromPath(file.path),\n path: file.path,\n source: \"vault\" as const,\n }));\n const adapterSkills = await listAgentSkillsFromAdapter(app);\n const globalSkills = await listGlobalAgentSkills();\n const skills = [...vaultSkills, ...adapterSkills, ...globalSkills]\n .filter((skill) => !normalizedQuery || skill.name.toLowerCase().includes(normalizedQuery))\n .sort((a, b) => a.name.localeCompare(b.name));\n\n const seen = new Set<string>();\n return skills.filter((skill) => {\n const key = `${skill.source}:${skill.name.toLowerCase()}`;\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\nasync function listAgentSkillsFromAdapter(app: App): Promise<SkillReference[]> {\n const roots = [\".agent/skills\", \".agents/skills\", \"agent/skills\", \"agents/skills\"];\n const files: string[] = [];\n for (const root of roots) {\n files.push(...await listAdapterFiles(app, root));\n }\n return files\n .filter((path) => isSkillPath(path))\n .map((path) => ({\n name: skillNameFromPath(path),\n path,\n source: \"vault\" as const,\n }));\n}\n\nasync function listGlobalAgentSkills(): Promise<SkillReference[]> {\n const roots = [\n path.join(homedir(), \".agent\", \"skills\"),\n path.join(homedir(), \".agents\", \"skills\"),\n ];\n const files: string[] = [];\n for (const root of roots) {\n files.push(...await listFilesystemFiles(root));\n }\n return files\n .filter((filePath) => isSkillPath(filePath))\n .map((filePath) => ({\n name: skillNameFromPath(filePath),\n path: filePath,\n source: \"global\" as const,\n }));\n}\n\nasync function listFilesystemFiles(folder: string): Promise<string[]> {\n try {\n const entries = await fs.readdir(folder, { withFileTypes: true });\n const results = await Promise.all(entries.map(async (entry) => {\n const childPath = path.join(folder, entry.name);\n if (entry.isDirectory()) return listFilesystemFiles(childPath);\n if (entry.isFile()) return [childPath];\n return [];\n }));\n return results.flat();\n } catch {\n return [];\n }\n}\n\nasync function listAdapterFiles(app: App, folder: string): Promise<string[]> {\n try {\n const listed = await app.vault.adapter.list(folder);\n const childFiles = listed.files;\n const nested = await Promise.all(listed.folders.map((child) => listAdapterFiles(app, child)));\n return [...childFiles, ...nested.flat()];\n } catch {\n return [];\n }\n}\n\nasync function readSelectedSkills(\n app: App,\n selectedSkillNames: string[],\n budget: ContextBudget,\n): Promise<AgentSkill[]> {\n const names = new Set(selectedSkillNames.map((name) => name.toLowerCase()));\n if (names.size === 0) return [];\n\n const skills: AgentSkill[] = [];\n const skillFiles = [...await listAgentSkillsFromAdapter(app), ...await listGlobalAgentSkills()];\n const vaultFiles = app.vault\n .getFiles()\n .filter((candidate) => isSkillFile(candidate))\n .map((file) => ({ name: skillNameFromPath(file.path), path: file.path, source: \"vault\" as const }));\n for (const skillFile of [...vaultFiles, ...skillFiles]) {\n const name = skillFile.name;\n if (!names.has(name.toLowerCase()) || !budget.hasRoom()) continue;\n const content = await readSkillContent(app, skillFile);\n skills.push({\n name,\n path: skillFile.path,\n content: budget.take(content),\n });\n }\n return skills;\n}\n\nasync function readSkillContent(app: App, skill: SkillReference): Promise<string> {\n if (skill.source === \"global\") return fs.readFile(skill.path, \"utf8\");\n const file = app.vault.getAbstractFileByPath(skill.path);\n if (file instanceof TFile) return app.vault.cachedRead(file);\n return app.vault.adapter.read(skill.path);\n}\n\nfunction isSkillFile(file: TFile): boolean {\n return isSkillPath(file.path);\n}\n\nfunction isSkillPath(path: string): boolean {\n const extension = path.split(\".\").last()?.toLowerCase() ?? \"\";\n if (![\"md\", \"txt\", \"json\", \"yaml\", \"yml\", \"prompt\"].includes(extension)) return false;\n const normalized = path.replace(/^\\/+/, \"\");\n return normalized.startsWith(\".agent/skills/\")\n || normalized.startsWith(\".agents/skills/\")\n || normalized.startsWith(\"agent/skills/\")\n || normalized.startsWith(\"agents/skills/\");\n}\n\nfunction skillNameFromPath(path: string): string {\n const parts = path.split(\"/\");\n const fileName = parts.last() ?? path;\n if (fileName.toLowerCase() === \"skill.md\" && parts.length > 1) {\n return parts[parts.length - 2];\n }\n return fileName.replace(/\\.[^.]+$/, \"\");\n}\n\nfunction parseFolderList(value: string): string[] {\n return value\n .split(\",\")\n .map((folder) => folder.trim().replace(/^\\/+/, \"\"))\n .filter(Boolean);\n}\n\nclass ContextBudget {\n private remainingChars: number;\n\n constructor(maxChars: number) {\n this.remainingChars = maxChars;\n }\n\n hasRoom(): boolean {\n return this.remainingChars > 0;\n }\n\n remaining(): number {\n return this.remainingChars;\n }\n\n take(content: string): string {\n const clipped = content.slice(0, this.remainingChars);\n this.remainingChars -= clipped.length;\n return clipped;\n }\n}\n\nfunction buildAgentInstructions(accessMode: AccessMode): string {\n return [\n \"You are running inside an Obsidian AI Sidebar plugin.\",\n \"Use the provided vault context to answer the user.\",\n \"If the prompt contains slash commands such as /writer or /reviewer, apply the matching selectedSkills instructions from the request context.\",\n \"The request may include conversation memory and per-request options such as model or reasoning effort.\",\n \"When you need file changes, include a JSON code block with this shape:\",\n '{\"actions\":[{\"type\":\"edit\",\"path\":\"Note.md\",\"content\":\"new content\"}]}',\n \"Allowed action types: create, edit, append, delete, rename.\",\n `Current access mode: ${accessMode}. The plugin enforces this mode before applying actions.`,\n ].join(\"\\n\");\n}\n\nasync function runProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n if (provider.authType === \"api-key\") {\n return runOpenAICompatibleProvider(provider, request);\n }\n\n if (provider.authType === \"oauth\") {\n if (!provider.accessToken) {\n throw new Error(\"This OAuth provider is not connected yet.\");\n }\n return runOAuthProvider(provider, request);\n }\n\n return runCliProvider(provider, request, app);\n}\n\nasync function runOpenAICompatibleProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiKey) throw new Error(\"Missing API key.\");\n const url = provider.apiBaseUrl || \"https://api.openai.com/v1/responses\";\n const model = request.options.model || provider.model || \"gpt-4.1-mini\";\n const userPayload = JSON.stringify({\n prompt: request.prompt,\n accessMode: request.accessMode,\n context: request.context,\n options: request.options,\n });\n const messages = [\n { role: \"system\", content: request.instructions },\n ...request.conversation.map((message) => ({\n role: message.role === \"assistant\" ? \"assistant\" : \"user\",\n content: message.content,\n })),\n { role: \"user\", content: userPayload },\n ];\n const usesChatCompletions = url.includes(\"/chat/completions\");\n const body: Record<string, unknown> = usesChatCompletions\n ? { model, messages }\n : { model, input: messages };\n if (request.options.reasoningEffort && model.startsWith(\"o\")) {\n body.reasoning = { effort: request.options.reasoningEffort };\n }\n const response = await requestUrl({\n url,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n return extractOpenAIText(response.json);\n}\n\nasync function runOAuthProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiBaseUrl) {\n throw new Error(\"OAuth provider needs an API endpoint after login.\");\n }\n const response = await requestUrl({\n url: provider.apiBaseUrl,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.accessToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(request),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n if (typeof response.json?.text === \"string\") return response.json.text;\n if (typeof response.json?.output_text === \"string\") return response.json.output_text;\n return response.text;\n}\n\nfunction runCliProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n return new Promise((resolve, reject) => {\n const prompt = cliPromptFromRequest(request);\n const command = cliCommandForRequest(provider, request, app);\n const shellCommand = `${command} ${shellQuote(prompt)}`;\n const child = spawn(\"/bin/zsh\", [\"-lc\", shellCommand], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n let stdout = \"\";\n let stderr = \"\";\n\n child.stdout.on(\"data\", (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) {\n resolve(stdout.trim());\n } else {\n reject(new Error(stderr.trim() || `Command exited with code ${code}. Check that ${command} is installed and available in your shell.`));\n }\n });\n\n });\n}\n\nfunction cliCommandForRequest(provider: AgentProvider, request: AgentRequest, app: App): string {\n const vaultPath = getVaultPath(app);\n if (provider.id === \"codex\" || provider.command.startsWith(\"codex exec\")) {\n const sandbox = providerSandboxForAccessMode(request.accessMode);\n const cdArg = vaultPath ? ` -C ${shellQuote(vaultPath)}` : \"\";\n return `${provider.command}${cdArg} --skip-git-repo-check -s ${sandbox}`;\n }\n return provider.command;\n}\n\nfunction providerSandboxForAccessMode(accessMode: AccessMode): string {\n if (accessMode === \"full-access\") return \"workspace-write\";\n return \"read-only\";\n}\n\nfunction getVaultPath(app: App): string | undefined {\n const adapter = app.vault.adapter;\n if (\"basePath\" in adapter && typeof adapter.basePath === \"string\") {\n return adapter.basePath;\n }\n return undefined;\n}\n\nfunction cliPromptFromRequest(request: AgentRequest): string {\n return [\n request.instructions,\n \"\",\n \"User request:\",\n request.prompt,\n \"\",\n \"Vault context and options:\",\n JSON.stringify({\n accessMode: request.accessMode,\n options: request.options,\n conversation: request.options.memoryEnabled ? request.conversation : [],\n context: request.context,\n }, null, 2),\n ].join(\"\\n\");\n}\n\nfunction openInteractiveTerminal(command: string, title: string): Promise<void> {\n const escapedCommand = escapeForSingleQuotedShell(command);\n const script = [\n `tell application \"Terminal\"`,\n \"activate\",\n `do script \"printf '\\\\\\\\033]0;${escapeForAppleScript(title)}\\\\\\\\007'; clear; echo 'AI Sidebar sign-in'; echo ''; echo 'Running: ${escapeForAppleScript(command)}'; echo ''; /bin/zsh -lc '${escapedCommand}; echo; echo Sign-in command finished. You can close this window when done.'\"`\n ,\n \"end tell\",\n ].join(\"\\n\");\n\n return new Promise((resolve, reject) => {\n const child = spawn(\"osascript\", [\"-e\", script], {\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n });\n let stderr = \"\";\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) resolve();\n else reject(new Error(stderr.trim() || `Could not open Terminal. Exit code ${code}`));\n });\n });\n}\n\nfunction extractOpenAIText(json: unknown): string {\n if (!json || typeof json !== \"object\") return \"\";\n const response = json as {\n output_text?: string;\n output?: Array<{ content?: Array<{ text?: string; type?: string }> }>;\n choices?: Array<{ message?: { content?: string } }>;\n };\n if (typeof response.output_text === \"string\") return response.output_text;\n if (typeof response.choices?.[0]?.message?.content === \"string\") {\n return response.choices[0].message.content;\n }\n const parts: string[] = [];\n for (const item of response.output ?? []) {\n for (const content of item.content ?? []) {\n if (typeof content.text === \"string\") parts.push(content.text);\n }\n }\n return parts.join(\"\\n\");\n}\n\nasync function startOAuthLogin(provider: AgentProvider): Promise<boolean> {\n if (!provider.authorizationUrl || !provider.clientId) {\n new Notice(\"Add an authorization URL and client ID first.\");\n return false;\n }\n\n const url = new URL(provider.authorizationUrl);\n url.searchParams.set(\"response_type\", \"token\");\n url.searchParams.set(\"client_id\", provider.clientId);\n url.searchParams.set(\"redirect_uri\", \"urn:ietf:wg:oauth:2.0:oob\");\n if (provider.scope) url.searchParams.set(\"scope\", provider.scope);\n url.searchParams.set(\"state\", crypto.randomUUID());\n\n window.open(url.toString());\n new Notice(\"OAuth sign-in opened in your browser. Paste the returned token into this provider when available.\");\n return true;\n}\n\nasync function startProviderSignIn(provider: AgentProvider): Promise<boolean> {\n if (provider.authType === \"cli\") {\n if (!provider.signInCommand) {\n new Notice(\"This local provider does not have a sign-in command configured.\");\n return false;\n }\n try {\n await openInteractiveTerminal(provider.signInCommand, `${provider.name} Sign In`);\n new Notice(`Opened Terminal for ${provider.name} sign-in.`);\n return true;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n new Notice(`Could not open sign-in: ${message}`);\n return false;\n }\n }\n\n if (provider.authType === \"api-key\") {\n if (provider.setupUrl) window.open(provider.setupUrl);\n new Notice(provider.apiKey ? `${provider.name} is connected.` : `Add your ${provider.name} API key below to connect.`);\n return Boolean(provider.apiKey);\n }\n\n return startOAuthLogin(provider);\n}\n\nfunction escapeForSingleQuotedShell(value: string): string {\n return value.replace(/'/g, \"'\\\\''\");\n}\n\nfunction shellQuote(value: string): string {\n return `'${escapeForSingleQuotedShell(value)}'`;\n}\n\nfunction escapeForAppleScript(value: string): string {\n return value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\nfunction providerConnectionActionText(provider: AgentProvider): string {\n if (provider.authType === \"cli\") return isProviderConnected(provider) ? \"Reconnect\" : \"Sign in\";\n if (provider.authType === \"api-key\") return provider.apiKey ? \"Connected\" : \"Get key\";\n return provider.accessToken ? \"Reconnect\" : \"Sign in\";\n}\n\nfunction providerConnectionLabel(provider: AgentProvider): string {\n if (isProviderConnected(provider)) return \"Connected\";\n if (provider.authType === \"api-key\") return \"Needs API key\";\n if (provider.authType === \"oauth\") return \"Needs sign-in\";\n return provider.signInCommand ? \"Ready to sign in\" : \"Needs command\";\n}\n\nfunction isProviderConnected(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken);\n return Boolean(provider.connectedAt);\n}\n\nfunction isProviderReady(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey && provider.apiBaseUrl);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken && provider.apiBaseUrl);\n return Boolean(provider.command.trim());\n}\n\nfunction normalizeProviders(providers: AgentProvider[]): AgentProvider[] {\n return providers.map((provider) => {\n const migrated = provider as AgentProvider;\n const command = migrated.id === \"codex\" && migrated.command === \"codex\"\n ? \"codex exec\"\n : migrated.command ?? \"\";\n return {\n ...migrated,\n authType: migrated.authType ?? (migrated.command ? \"cli\" : \"api-key\"),\n command,\n connectedAt: migrated.connectedAt,\n };\n });\n}\n\nfunction cloneProvider(provider: AgentProvider): AgentProvider {\n return { ...provider };\n}\n\nfunction extractVaultActions(response: string): VaultAction[] {\n const actions: VaultAction[] = [];\n const codeBlockPattern = /```(?:json)?\\s*([\\s\\S]*?)```/gi;\n const candidates: string[] = [];\n let match: RegExpExecArray | null;\n\n while ((match = codeBlockPattern.exec(response)) !== null) {\n candidates.push(match[1]);\n }\n candidates.push(response);\n\n for (const candidate of candidates) {\n try {\n const parsed = JSON.parse(candidate.trim()) as { actions?: VaultAction[] };\n if (Array.isArray(parsed.actions)) {\n actions.push(...parsed.actions.filter(isVaultAction));\n }\n } catch {\n continue;\n }\n }\n\n return actions;\n}\n\nfunction isVaultAction(value: unknown): value is VaultAction {\n if (!value || typeof value !== \"object\") return false;\n const action = value as Partial<VaultAction>;\n if (typeof action.type !== \"string\" || typeof action.path !== \"string\") return false;\n if ([\"create\", \"edit\", \"append\"].includes(action.type)) {\n return typeof (action as { content?: unknown }).content === \"string\";\n }\n if (action.type === \"delete\") return true;\n if (action.type === \"rename\") {\n return typeof (action as { newPath?: unknown }).newPath === \"string\";\n }\n return false;\n}\n\nfunction confirmVaultActions(app: App, actions: VaultAction[]): Promise<boolean> {\n return new Promise((resolve) => {\n new ConfirmActionsModal(app, actions, resolve).open();\n });\n}\n\nasync function applyVaultAction(app: App, action: VaultAction): Promise<void> {\n const path = normalizeVaultPath(action.path);\n const file = app.vault.getAbstractFileByPath(path);\n\n if (action.type === \"create\") {\n if (file) throw new Error(\"File already exists.\");\n await ensureParentFolder(app, path);\n await app.vault.create(path, action.content);\n return;\n }\n\n if (!(file instanceof TFile)) {\n throw new Error(\"File does not exist.\");\n }\n\n if (action.type === \"edit\") {\n await app.vault.modify(file, action.content);\n } else if (action.type === \"append\") {\n const current = await app.vault.read(file);\n await app.vault.modify(file, `${current}${current.endsWith(\"\\n\") ? \"\" : \"\\n\"}${action.content}`);\n } else if (action.type === \"delete\") {\n await app.vault.delete(file);\n } else if (action.type === \"rename\") {\n await ensureParentFolder(app, action.newPath);\n await app.fileManager.renameFile(file, normalizeVaultPath(action.newPath));\n }\n}\n\nasync function ensureParentFolder(app: App, path: string): Promise<void> {\n const parts = normalizeVaultPath(path).split(\"/\");\n parts.pop();\n let current = \"\";\n for (const part of parts) {\n current = current ? `${current}/${part}` : part;\n if (!app.vault.getAbstractFileByPath(current)) {\n await app.vault.createFolder(current);\n }\n }\n}\n\nfunction normalizeVaultPath(path: string): string {\n return path.replace(/^\\/+/, \"\").replace(/\\\\/g, \"/\");\n}\n\nfunction uniqueFiles(files: TFile[]): TFile[] {\n const seen = new Set<string>();\n return files.filter((file) => {\n if (seen.has(file.path)) return false;\n seen.add(file.path);\n return true;\n });\n}\n\nfunction sanitizeProviderId(value: string): string {\n return value.toLowerCase().replace(/[^a-z0-9_-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || \"provider\";\n}\n\nfunction isReasoningEffort(value: string): value is ReasoningEffort {\n return value === \"low\" || value === \"medium\" || value === \"high\";\n}\n\nfunction isAccessMode(value: string): value is AccessMode {\n return value === \"read-only\" || value === \"confirm\" || value === \"full-access\";\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"}, "1ef19eb9987bc604b0f6f9dd3efa1e0dca93c144": {"t": 1785759150.9396505, "n": 67442, "h": "a4f63731b29a5c1d", "c": "import {\n App,\n ButtonComponent,\n DropdownComponent,\n ItemView,\n MarkdownView,\n Modal,\n Notice,\n Plugin,\n PluginSettingTab,\n requestUrl,\n Setting,\n TFile,\n WorkspaceLeaf,\n} from \"obsidian\";\nimport { spawn } from \"child_process\";\nimport { promises as fs } from \"fs\";\nimport { homedir } from \"os\";\nimport * as path from \"path\";\n\nconst VIEW_TYPE_AI_SIDEBAR = \"ai-sidebar-view\";\n\ntype AccessMode = \"read-only\" | \"confirm\" | \"full-access\";\ntype ProviderAuthType = \"api-key\" | \"oauth\" | \"cli\";\ntype ReasoningEffort = \"low\" | \"medium\" | \"high\";\n\ninterface AgentProvider {\n id: string;\n name: string;\n authType: ProviderAuthType;\n command: string;\n signInCommand?: string;\n setupUrl?: string;\n connectedAt?: number;\n apiBaseUrl?: string;\n apiKey?: string;\n model?: string;\n models?: string;\n reasoningEffort?: ReasoningEffort;\n authorizationUrl?: string;\n tokenUrl?: string;\n clientId?: string;\n scope?: string;\n accessToken?: string;\n refreshToken?: string;\n tokenExpiresAt?: number;\n}\n\ninterface AISidebarSettings {\n providers: AgentProvider[];\n defaultProviderId: string;\n defaultAccessMode: AccessMode;\n includeFolders: string;\n excludeFolders: string;\n maxContextChars: number;\n enableConversationMemory: boolean;\n maxMemoryMessages: number;\n rememberedMessages: ChatMessage[];\n}\n\ninterface VaultContextFile {\n path: string;\n content: string;\n reason: string;\n}\n\ninterface AgentSkill {\n name: string;\n path: string;\n content: string;\n}\n\ninterface SkillReference {\n name: string;\n path: string;\n source: \"vault\" | \"global\";\n}\n\ninterface VaultContext {\n activeFile?: VaultContextFile;\n selection?: string;\n linkedFiles: VaultContextFile[];\n backlinkFiles: VaultContextFile[];\n relevantFiles: VaultContextFile[];\n baseFiles: VaultContextFile[];\n selectedSkills: AgentSkill[];\n}\n\ninterface ChatMessage {\n role: \"user\" | \"assistant\" | \"system\";\n content: string;\n}\n\ninterface AgentRequest {\n prompt: string;\n accessMode: AccessMode;\n context: VaultContext;\n instructions: string;\n conversation: ChatMessage[];\n options: AgentRequestOptions;\n}\n\ninterface AgentRequestOptions {\n model?: string;\n reasoningEffort?: ReasoningEffort;\n memoryEnabled: boolean;\n}\n\ninterface ParsedPrompt {\n cleanPrompt: string;\n selectedSkillNames: string[];\n options: AgentRequestOptions;\n accessMode?: AccessMode;\n}\n\ntype VaultAction =\n | { type: \"create\"; path: string; content: string }\n | { type: \"edit\"; path: string; content: string }\n | { type: \"append\"; path: string; content: string }\n | { type: \"delete\"; path: string }\n | { type: \"rename\"; path: string; newPath: string };\n\ninterface ProviderPreset {\n id: string;\n name: string;\n description: string;\n provider: AgentProvider;\n}\n\nconst PROVIDER_PRESETS: ProviderPreset[] = [\n {\n id: \"codex\",\n name: \"Codex\",\n description: \"Sign in with Codex locally and use the Codex CLI agent.\",\n provider: {\n id: \"codex\",\n name: \"Codex\",\n authType: \"cli\",\n command: \"codex exec\",\n signInCommand: \"codex login\",\n models: \"gpt-5.4, gpt-5.4-mini, gpt-5.3-codex\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"claude\",\n name: \"Claude Code\",\n description: \"Sign in with your Claude subscription or Anthropic Console account.\",\n provider: {\n id: \"claude\",\n name: \"Claude Code\",\n authType: \"cli\",\n command: \"claude\",\n signInCommand: \"claude auth login\",\n setupUrl: \"https://code.claude.com/docs/en/cli-usage\",\n models: \"opus, sonnet, haiku\",\n },\n },\n {\n id: \"opencode\",\n name: \"opencode\",\n description: \"Connect opencode to any provider it supports, including subscription/API accounts.\",\n provider: {\n id: \"opencode\",\n name: \"opencode\",\n authType: \"cli\",\n command: \"opencode\",\n signInCommand: \"opencode auth login\",\n setupUrl: \"https://opencode.ai/docs/cli/\",\n models: \"anthropic/claude-sonnet-4-5, openai/gpt-5.4, google/gemini-2.5-pro\",\n },\n },\n {\n id: \"openai\",\n name: \"OpenAI API\",\n description: \"Use an OpenAI API key with the Responses API.\",\n provider: {\n id: \"openai\",\n name: \"OpenAI\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.openai.com/api-keys\",\n apiBaseUrl: \"https://api.openai.com/v1/responses\",\n model: \"gpt-4.1-mini\",\n models: \"gpt-4.1-mini, gpt-4.1, o4-mini\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n description: \"Use an Anthropic API key directly through an OpenAI-compatible proxy endpoint.\",\n provider: {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.anthropic.com/settings/keys\",\n apiBaseUrl: \"\",\n model: \"claude-sonnet-4-5\",\n models: \"claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5\",\n },\n },\n {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n description: \"Use Google Gemini CLI with its native sign-in and subscription/API setup.\",\n provider: {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n authType: \"cli\",\n command: \"gemini\",\n signInCommand: \"gemini\",\n setupUrl: \"https://google-gemini.github.io/gemini-cli/docs/\",\n models: \"gemini-2.5-pro, gemini-2.5-flash\",\n },\n },\n {\n id: \"openrouter\",\n name: \"OpenRouter\",\n description: \"Use one API key for many model providers through an OpenAI-compatible endpoint.\",\n provider: {\n id: \"openrouter\",\n name: \"OpenRouter\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://openrouter.ai/settings/keys\",\n apiBaseUrl: \"https://openrouter.ai/api/v1/chat/completions\",\n model: \"openai/gpt-4.1-mini\",\n models: \"openai/gpt-4.1-mini, anthropic/claude-sonnet-4.5, google/gemini-2.5-pro\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"deepseek\",\n name: \"DeepSeek\",\n description: \"Use DeepSeek with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.deepseek.com/api_keys\",\n apiBaseUrl: \"https://api.deepseek.com/v1/chat/completions\",\n model: \"deepseek-chat\",\n models: \"deepseek-chat, deepseek-reasoner\",\n },\n },\n {\n id: \"mistral\",\n name: \"Mistral\",\n description: \"Use Mistral models with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"mistral\",\n name: \"Mistral\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.mistral.ai/api-keys\",\n apiBaseUrl: \"https://api.mistral.ai/v1/chat/completions\",\n model: \"mistral-large-latest\",\n models: \"mistral-large-latest, codestral-latest, ministral-8b-latest\",\n },\n },\n {\n id: \"aider\",\n name: \"Aider\",\n description: \"Use Aider with keys from OpenAI, Anthropic, Gemini, OpenRouter, DeepSeek, and more.\",\n provider: {\n id: \"aider\",\n name: \"Aider\",\n authType: \"cli\",\n command: \"aider\",\n signInCommand: \"aider\",\n setupUrl: \"https://aider.chat/docs/config/api-keys.html\",\n models: \"sonnet, opus, gpt-4.1, o4-mini, gemini/gemini-2.5-pro\",\n },\n },\n];\n\nconst DEFAULT_SETTINGS: AISidebarSettings = {\n providers: [\n cloneProvider(PROVIDER_PRESETS[0].provider),\n cloneProvider(PROVIDER_PRESETS[3].provider),\n ],\n defaultProviderId: \"codex\",\n defaultAccessMode: \"confirm\",\n includeFolders: \"\",\n excludeFolders: \".obsidian, node_modules, .git\",\n maxContextChars: 45000,\n enableConversationMemory: true,\n maxMemoryMessages: 30,\n rememberedMessages: [],\n};\n\nexport default class AISidebarPlugin extends Plugin {\n settings: AISidebarSettings;\n\n async onload() {\n await this.loadSettings();\n\n this.registerView(\n VIEW_TYPE_AI_SIDEBAR,\n (leaf) => new AISidebarView(leaf, this),\n );\n\n this.addRibbonIcon(\"sparkles\", \"Toggle AI Sidebar\", () => {\n void this.toggleSidebar();\n });\n\n this.addCommand({\n id: \"toggle-ai-sidebar\",\n name: \"Toggle AI Sidebar\",\n hotkeys: [{ modifiers: [\"Mod\", \"Shift\"], key: \"A\" }],\n callback: () => {\n void this.toggleSidebar();\n },\n });\n\n this.addSettingTab(new AISidebarSettingTab(this.app, this));\n }\n\n onunload() {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n }\n\n async toggleSidebar() {\n const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AI_SIDEBAR).first();\n if (existingLeaf) {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n return;\n }\n\n const leaf = this.app.workspace.getRightLeaf(false);\n if (!leaf) {\n new Notice(\"Could not open the AI sidebar.\");\n return;\n }\n\n await leaf.setViewState({ type: VIEW_TYPE_AI_SIDEBAR, active: true });\n this.app.workspace.revealLeaf(leaf);\n }\n\n async loadSettings() {\n const loaded = await this.loadData();\n this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);\n this.settings.providers = normalizeProviders(this.settings.providers);\n this.settings.rememberedMessages = Array.isArray(this.settings.rememberedMessages)\n ? this.settings.rememberedMessages\n : [];\n }\n\n async saveSettings() {\n await this.saveData(this.settings);\n }\n\n getProvider(id: string): AgentProvider | undefined {\n return this.settings.providers.find((provider) => provider.id === id);\n }\n\n async rememberMessages(messages: ChatMessage[]) {\n if (!this.settings.enableConversationMemory) return;\n this.settings.rememberedMessages = messages.slice(-this.settings.maxMemoryMessages);\n await this.saveSettings();\n }\n\n async clearMemory() {\n this.settings.rememberedMessages = [];\n await this.saveSettings();\n }\n\n async upsertProviderFromPreset(preset: ProviderPreset) {\n const provider = cloneProvider(preset.provider);\n const existingIndex = this.settings.providers.findIndex((item) => item.id === provider.id);\n if (existingIndex >= 0) {\n this.settings.providers[existingIndex] = {\n ...provider,\n apiKey: this.settings.providers[existingIndex].apiKey,\n accessToken: this.settings.providers[existingIndex].accessToken,\n refreshToken: this.settings.providers[existingIndex].refreshToken,\n };\n } else {\n this.settings.providers.push(provider);\n }\n this.settings.defaultProviderId = provider.id;\n await this.saveSettings();\n }\n}\n\nclass AISidebarView extends ItemView {\n private plugin: AISidebarPlugin;\n private messages: ChatMessage[] = [];\n private providerId: string;\n private accessMode: AccessMode;\n private threadEl: HTMLElement;\n private textareaEl: HTMLTextAreaElement;\n private contextEl: HTMLElement;\n private slashEl: HTMLElement;\n private modelBadgeEl: HTMLElement;\n private activeContextPath = \"\";\n private refreshTimer: number | undefined;\n private sendButton: ButtonComponent;\n private isRunning = false;\n\n constructor(leaf: WorkspaceLeaf, plugin: AISidebarPlugin) {\n super(leaf);\n this.plugin = plugin;\n this.messages = plugin.settings.enableConversationMemory\n ? [...plugin.settings.rememberedMessages]\n : [];\n this.providerId = plugin.settings.defaultProviderId;\n this.accessMode = plugin.settings.defaultAccessMode;\n }\n\n getViewType(): string {\n return VIEW_TYPE_AI_SIDEBAR;\n }\n\n getDisplayText(): string {\n return \"AI Sidebar\";\n }\n\n getIcon(): string {\n return \"sparkles\";\n }\n\n async onOpen() {\n this.render();\n this.registerEvent(\n this.app.workspace.on(\"active-leaf-change\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.workspace.on(\"file-open\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.vault.on(\"modify\", (file) => {\n if (file instanceof TFile && file.path === this.activeContextPath) {\n this.scheduleContextRefresh();\n }\n }),\n );\n await this.refreshContextPreview();\n }\n\n async onClose() {\n this.contentEl.empty();\n }\n\n private render() {\n this.contentEl.empty();\n this.contentEl.addClass(\"ai-sidebar\");\n\n const header = this.contentEl.createDiv(\"ai-sidebar__header\");\n header.createDiv({ cls: \"ai-sidebar__title\", text: \"AI Sidebar\" });\n header.createDiv({ cls: \"ai-sidebar__subtitle\", text: \"Vault-aware agents\" });\n\n const controls = this.contentEl.createDiv(\"ai-sidebar__controls\");\n new DropdownComponent(controls)\n .addOptions(this.providerOptions())\n .setValue(this.providerId)\n .onChange((value) => {\n this.providerId = value;\n this.updateModelBadge();\n });\n\n new DropdownComponent(controls)\n .addOptions({\n \"read-only\": \"Read only\",\n confirm: \"Confirm actions\",\n \"full-access\": \"Full access\",\n })\n .setValue(this.accessMode)\n .onChange((value: AccessMode) => {\n this.accessMode = value;\n void this.refreshContextPreview();\n });\n\n this.contextEl = this.contentEl.createDiv(\"ai-sidebar__context\");\n\n this.threadEl = this.contentEl.createDiv(\"ai-sidebar__thread\");\n this.renderMessages();\n\n const composer = this.contentEl.createDiv(\"ai-sidebar__composer\");\n this.textareaEl = composer.createEl(\"textarea\", {\n cls: \"ai-sidebar__input\",\n attr: {\n placeholder: \"Ask about your vault, draft a note, or request an edit...\",\n rows: \"4\",\n },\n });\n\n this.textareaEl.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Escape\" && !this.slashEl.hasClass(\"is-hidden\")) {\n event.preventDefault();\n this.hideSlashCommands();\n return;\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n void this.sendPrompt();\n }\n });\n this.textareaEl.addEventListener(\"input\", () => {\n void this.updateSlashCommands();\n this.updateModelBadge();\n });\n\n this.slashEl = composer.createDiv(\"ai-sidebar__slash is-hidden\");\n\n const actions = composer.createDiv(\"ai-sidebar__composer-actions\");\n new ButtonComponent(actions)\n .setButtonText(\"Refresh context\")\n .onClick(() => {\n void this.refreshContextPreview();\n });\n new ButtonComponent(actions)\n .setButtonText(\"Clear\")\n .onClick(() => {\n void this.clearConversation();\n });\n\n this.modelBadgeEl = actions.createDiv(\"ai-sidebar__model-badge\");\n this.updateModelBadge();\n\n this.sendButton = new ButtonComponent(actions)\n .setCta()\n .setButtonText(\"Send\")\n .onClick(() => {\n void this.sendPrompt();\n });\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderMessages() {\n this.threadEl.empty();\n\n if (this.messages.length === 0) {\n const empty = this.threadEl.createDiv(\"ai-sidebar__empty\");\n empty.createDiv({ text: \"Ask a local agent to work with the notes in this vault.\" });\n empty.createDiv({ text: \"Use confirm mode when you want to review file edits first.\" });\n return;\n }\n\n for (const message of this.messages) {\n const messageEl = this.threadEl.createDiv(`ai-sidebar__message ai-sidebar__message--${message.role}`);\n messageEl.createDiv({ cls: \"ai-sidebar__message-role\", text: message.role });\n messageEl.createEl(\"pre\", { text: message.content });\n }\n\n if (this.isRunning) {\n const thinkingEl = this.threadEl.createDiv(\"ai-sidebar__message ai-sidebar__message--thinking\");\n thinkingEl.createDiv({ cls: \"ai-sidebar__message-role\", text: \"assistant\" });\n const row = thinkingEl.createDiv(\"ai-sidebar__typing\");\n row.createSpan({ text: \"Thinking\" });\n const dots = row.createSpan(\"ai-sidebar__typing-dots\");\n dots.createSpan();\n dots.createSpan();\n dots.createSpan();\n }\n\n this.threadEl.scrollTo({ top: this.threadEl.scrollHeight });\n }\n\n private async refreshContextPreview(prompt = \"\") {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n prompt,\n this.extractSelectedSkillNames(prompt),\n );\n this.activeContextPath = context.activeFile?.path ?? \"\";\n this.contextEl.empty();\n this.contextEl.createDiv({\n cls: \"ai-sidebar__context-title\",\n text: `Context preview · ${this.accessModeLabel()}`,\n });\n\n const items = [\n context.activeFile ? `Active: ${context.activeFile.path}` : \"No active note\",\n context.selection ? \"Selection included\" : \"No selection\",\n `${context.linkedFiles.length} linked`,\n `${context.backlinkFiles.length} backlinks`,\n `${context.relevantFiles.length} relevant`,\n `${context.baseFiles.length} bases`,\n `${context.selectedSkills.length} skills`,\n ];\n\n const list = this.contextEl.createEl(\"ul\");\n for (const item of items) {\n list.createEl(\"li\", { text: item });\n }\n }\n\n private scheduleContextRefresh() {\n window.clearTimeout(this.refreshTimer);\n this.refreshTimer = window.setTimeout(() => {\n void this.refreshContextPreview(this.textareaEl?.value ?? \"\");\n }, 150);\n }\n\n private accessModeLabel(): string {\n if (this.accessMode === \"read-only\") return \"read only\";\n if (this.accessMode === \"full-access\") return \"full access\";\n return \"confirm actions\";\n }\n\n private async sendPrompt() {\n const rawPrompt = this.textareaEl.value.trim();\n if (!rawPrompt || this.isRunning) return;\n\n const provider = this.plugin.getProvider(this.providerId);\n if (!provider) {\n new Notice(\"Choose a provider in AI Sidebar settings.\");\n return;\n }\n\n if (!isProviderReady(provider)) {\n new Notice(\"Connect or configure this provider in AI Sidebar settings first.\");\n return;\n }\n\n const parsed = this.parsePromptControls(rawPrompt, provider);\n if (this.isSlashOnlyPrompt(rawPrompt, parsed)) {\n await this.applySlashControls(provider, parsed);\n this.textareaEl.value = \"\";\n this.updateModelBadge();\n await this.refreshContextPreview();\n return;\n }\n\n this.isRunning = true;\n this.sendButton.setButtonText(\"Running...\");\n this.sendButton.setDisabled(true);\n this.textareaEl.value = \"\";\n const effectiveAccessMode = parsed.accessMode ?? this.accessMode;\n this.messages.push({ role: \"user\", content: rawPrompt });\n this.renderMessages();\n\n try {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n parsed.cleanPrompt,\n parsed.selectedSkillNames,\n );\n await this.refreshContextPreview(rawPrompt);\n const request: AgentRequest = {\n prompt: parsed.cleanPrompt,\n accessMode: effectiveAccessMode,\n context,\n instructions: buildAgentInstructions(effectiveAccessMode),\n conversation: this.plugin.settings.enableConversationMemory ? this.messages.slice(0, -1) : [],\n options: parsed.options,\n };\n\n const response = await runProvider(provider, request, this.app);\n const actionResult = await this.handleActions(response);\n const assistantContent = actionResult ? `${response}\\n\\n${actionResult}` : response;\n this.messages.push({ role: \"assistant\", content: assistantContent.trim() || \"No response.\" });\n await this.plugin.rememberMessages(this.messages);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n this.messages.push({ role: \"system\", content: `Provider failed: ${message}` });\n await this.plugin.rememberMessages(this.messages);\n new Notice(\"AI provider failed. Check the sidebar for details.\");\n } finally {\n this.isRunning = false;\n this.sendButton.setButtonText(\"Send\");\n this.sendButton.setDisabled(false);\n this.updateModelBadge();\n this.renderMessages();\n }\n }\n\n private async clearConversation() {\n this.messages = [];\n await this.plugin.clearMemory();\n this.renderMessages();\n new Notice(\"AI Sidebar memory cleared.\");\n }\n\n private async handleActions(response: string): Promise<string> {\n const actions = extractVaultActions(response);\n if (actions.length === 0) return \"\";\n\n if (this.accessMode === \"read-only\") {\n return \"Vault actions were proposed, but read-only mode blocked all writes.\";\n }\n\n if (this.accessMode === \"confirm\") {\n const approved = await confirmVaultActions(this.app, actions);\n if (!approved) return \"Vault actions were not applied.\";\n }\n\n const results: string[] = [];\n for (const action of actions) {\n try {\n await applyVaultAction(this.app, action);\n results.push(`Applied ${action.type}: ${action.path}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n results.push(`Failed ${action.type}: ${action.path} · ${message}`);\n }\n }\n return results.join(\"\\n\");\n }\n\n private async updateSlashCommands() {\n const query = this.currentSlashQuery();\n if (query === null) {\n this.hideSlashCommands();\n return;\n }\n\n const provider = this.plugin.getProvider(this.providerId);\n const commands = provider ? nativeSlashCommands(provider, query, this.accessMode) : [];\n const skills = await listAgentSkills(this.plugin.app, query);\n this.slashEl.empty();\n\n if (commands.length === 0 && skills.length === 0) {\n this.hideSlashCommands();\n return;\n }\n\n this.slashEl.removeClass(\"is-hidden\");\n for (const command of commands.slice(0, 6)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: command.label });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: command.description });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(command.insert);\n });\n }\n for (const skill of skills.slice(0, 10)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: `/${skill.name}` });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: skill.path });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(`/${skill.name}`);\n });\n }\n }\n\n private hideSlashCommands() {\n this.slashEl.empty();\n this.slashEl.addClass(\"is-hidden\");\n }\n\n private currentSlashQuery(): string | null {\n const cursor = this.textareaEl.selectionStart;\n const beforeCursor = this.textareaEl.value.slice(0, cursor);\n const match = beforeCursor.match(/(?:^|\\s)\\/([A-Za-z0-9_.:-]*)$/);\n return match ? match[1].toLowerCase() : null;\n }\n\n private insertSlashToken(token: string) {\n const cursor = this.textareaEl.selectionStart;\n const value = this.textareaEl.value;\n const beforeCursor = value.slice(0, cursor);\n const afterCursor = value.slice(cursor);\n const replaced = beforeCursor.replace(/(?:^|\\s)\\/([A-Za-z0-9_.:-]*)$/, (match) => {\n const prefix = match.startsWith(\" \") ? \" \" : \"\";\n return `${prefix}${token} `;\n });\n this.textareaEl.value = `${replaced}${afterCursor}`;\n this.textareaEl.focus();\n this.textareaEl.selectionStart = replaced.length;\n this.textareaEl.selectionEnd = replaced.length;\n this.hideSlashCommands();\n this.scheduleContextRefresh();\n }\n\n private extractSelectedSkillNames(prompt: string): string[] {\n const native = new Set([\"model\", \"reasoning\", \"access\", \"memory\"]);\n return Array.from(prompt.matchAll(/(?:^|\\s)\\/([A-Za-z0-9_.-]+)/g), (match) => match[1])\n .filter((name) => !native.has(name.split(\":\")[0].toLowerCase()));\n }\n\n private parsePromptControls(prompt: string, provider: AgentProvider): ParsedPrompt {\n const options: AgentRequestOptions = {\n model: provider.model,\n reasoningEffort: provider.reasoningEffort,\n memoryEnabled: this.plugin.settings.enableConversationMemory,\n };\n let accessMode: AccessMode | undefined;\n\n const cleanPrompt = prompt.replace(/(?:^|\\s)\\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match, key, value) => {\n const normalizedKey = String(key).toLowerCase();\n const normalizedValue = String(value).toLowerCase();\n if (normalizedKey === \"model\") {\n options.model = value;\n } else if (normalizedKey === \"reasoning\" && isReasoningEffort(normalizedValue)) {\n options.reasoningEffort = normalizedValue;\n } else if (normalizedKey === \"access\" && isAccessMode(normalizedValue)) {\n accessMode = normalizedValue;\n } else if (normalizedKey === \"memory\") {\n options.memoryEnabled = normalizedValue === \"on\";\n }\n return match.startsWith(\" \") ? \" \" : \"\";\n }).trim();\n\n return {\n cleanPrompt,\n selectedSkillNames: this.extractSelectedSkillNames(prompt),\n options,\n accessMode,\n };\n }\n\n private isSlashOnlyPrompt(prompt: string, parsed: ParsedPrompt): boolean {\n return parsed.cleanPrompt.length === 0 && this.extractSelectedSkillNames(prompt).length === 0;\n }\n\n private async applySlashControls(provider: AgentProvider, parsed: ParsedPrompt) {\n const changes: string[] = [];\n if (parsed.options.model && parsed.options.model !== provider.model) {\n provider.model = parsed.options.model;\n changes.push(`model ${parsed.options.model}`);\n }\n if (parsed.options.reasoningEffort && parsed.options.reasoningEffort !== provider.reasoningEffort) {\n provider.reasoningEffort = parsed.options.reasoningEffort;\n changes.push(`reasoning ${parsed.options.reasoningEffort}`);\n }\n if (parsed.accessMode && parsed.accessMode !== this.accessMode) {\n this.accessMode = parsed.accessMode;\n this.plugin.settings.defaultAccessMode = parsed.accessMode;\n changes.push(`access ${this.accessModeLabel()}`);\n }\n if (parsed.options.memoryEnabled !== this.plugin.settings.enableConversationMemory) {\n this.plugin.settings.enableConversationMemory = parsed.options.memoryEnabled;\n changes.push(`memory ${parsed.options.memoryEnabled ? \"on\" : \"off\"}`);\n }\n await this.plugin.saveSettings();\n new Notice(changes.length > 0 ? `Updated ${changes.join(\", \")}.` : \"Slash command applied.\");\n }\n\n private updateModelBadge() {\n if (!this.modelBadgeEl) return;\n const provider = this.plugin.getProvider(this.providerId);\n const override = this.textareaEl?.value.match(/(?:^|\\s)\\/model:([A-Za-z0-9_.:/-]+)/i)?.[1];\n const model = override || provider?.model || parseProviderModels(provider ?? DEFAULT_SETTINGS.providers[0]).first() || \"default\";\n this.modelBadgeEl.setText(model);\n this.modelBadgeEl.toggleClass(\"is-override\", Boolean(override));\n this.modelBadgeEl.setAttr(\"aria-label\", override ? `Model override: ${model}` : `Model: ${model}`);\n }\n}\n\nclass AISidebarSettingTab extends PluginSettingTab {\n plugin: AISidebarPlugin;\n\n constructor(app: App, plugin: AISidebarPlugin) {\n super(app, plugin);\n this.plugin = plugin;\n }\n\n display(): void {\n const { containerEl } = this;\n containerEl.empty();\n containerEl.createEl(\"h2\", { text: \"AI Sidebar\" });\n\n new Setting(containerEl)\n .setName(\"Default provider\")\n .setDesc(\"The AI provider used when the sidebar opens.\")\n .addDropdown((dropdown) => {\n dropdown.addOptions(this.providerOptions());\n dropdown.setValue(this.plugin.settings.defaultProviderId);\n dropdown.onChange(async (value) => {\n this.plugin.settings.defaultProviderId = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Default vault access\")\n .setDesc(\"How much permission the agent has when it proposes changes.\")\n .addDropdown((dropdown) => {\n dropdown\n .addOption(\"read-only\", \"Read only\")\n .addOption(\"confirm\", \"Confirm actions\")\n .addOption(\"full-access\", \"Full access\")\n .setValue(this.plugin.settings.defaultAccessMode)\n .onChange(async (value: AccessMode) => {\n this.plugin.settings.defaultAccessMode = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Include folders\")\n .setDesc(\"Comma-separated folder prefixes to include. Leave blank for the whole vault.\")\n .addText((text) => {\n text\n .setPlaceholder(\"Projects, Daily Notes\")\n .setValue(this.plugin.settings.includeFolders)\n .onChange(async (value) => {\n this.plugin.settings.includeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Exclude folders\")\n .setDesc(\"Comma-separated folder prefixes that should never be sent to an agent.\")\n .addText((text) => {\n text\n .setValue(this.plugin.settings.excludeFolders)\n .onChange(async (value) => {\n this.plugin.settings.excludeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Max context characters\")\n .setDesc(\"Upper bound for note text sent to the agent.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxContextChars))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed > 1000) {\n this.plugin.settings.maxContextChars = parsed;\n await this.plugin.saveSettings();\n }\n });\n });\n\n new Setting(containerEl)\n .setName(\"Conversation memory\")\n .setDesc(\"Remember previous sidebar messages after closing and reopening Obsidian.\")\n .addToggle((toggle) => {\n toggle\n .setValue(this.plugin.settings.enableConversationMemory)\n .onChange(async (value) => {\n this.plugin.settings.enableConversationMemory = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Memory length\")\n .setDesc(\"Maximum number of recent sidebar messages to remember.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxMemoryMessages))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed >= 2) {\n this.plugin.settings.maxMemoryMessages = parsed;\n await this.plugin.saveSettings();\n }\n });\n })\n .addButton((button) => {\n button.setButtonText(\"Clear memory\").onClick(async () => {\n await this.plugin.clearMemory();\n new Notice(\"AI Sidebar memory cleared.\");\n });\n });\n\n containerEl.createEl(\"h3\", { text: \"Connect a Provider\" });\n this.renderProviderPresets(containerEl);\n\n containerEl.createEl(\"h3\", { text: \"Connected Providers\" });\n for (const provider of this.plugin.settings.providers) {\n this.renderProviderSetting(containerEl, provider);\n }\n\n new Setting(containerEl)\n .setName(\"Advanced provider\")\n .setDesc(\"Create a custom API, OAuth, or local CLI provider.\")\n .addButton((button) => {\n button.setButtonText(\"Add custom\").onClick(async () => {\n const id = `provider-${Date.now()}`;\n this.plugin.settings.providers.push({ id, name: \"New provider\", authType: \"oauth\", command: \"\" });\n await this.plugin.saveSettings();\n this.display();\n });\n });\n }\n\n private renderProviderPresets(containerEl: HTMLElement) {\n const grid = containerEl.createDiv(\"ai-sidebar-provider-grid\");\n for (const preset of PROVIDER_PRESETS) {\n const provider = this.plugin.getProvider(preset.provider.id);\n const card = grid.createDiv(\"ai-sidebar-provider-card\");\n card.createDiv({ cls: \"ai-sidebar-provider-card__name\", text: preset.name });\n card.createDiv({ cls: \"ai-sidebar-provider-card__desc\", text: preset.description });\n const status = provider ? providerConnectionLabel(provider) : \"Not added\";\n card.createDiv({ cls: \"ai-sidebar-provider-card__status\", text: status });\n\n const actions = card.createDiv(\"ai-sidebar-provider-card__actions\");\n new ButtonComponent(actions)\n .setButtonText(provider ? \"Use\" : \"Add\")\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n this.display();\n });\n\n new ButtonComponent(actions)\n .setCta()\n .setButtonText(providerConnectionActionText(provider ?? preset.provider))\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n const connectedProvider = this.plugin.getProvider(preset.provider.id) ?? preset.provider;\n if (await startProviderSignIn(connectedProvider)) {\n connectedProvider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n }\n this.display();\n });\n }\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderProviderSetting(containerEl: HTMLElement, provider: AgentProvider) {\n const wrapper = containerEl.createDiv(\"ai-sidebar-settings-provider\");\n const header = wrapper.createDiv(\"ai-sidebar-settings-provider__header\");\n const title = header.createDiv(\"ai-sidebar-settings-provider__title\");\n title.createDiv({ cls: \"ai-sidebar-settings-provider__name\", text: provider.name || provider.id });\n title.createDiv({ cls: \"ai-sidebar-settings-provider__desc\", text: this.providerDescription(provider) });\n header.createSpan({\n cls: `ai-sidebar-settings-provider__badge ${isProviderConnected(provider) ? \"is-connected\" : \"\"}`,\n text: providerConnectionLabel(provider),\n });\n\n const actions = wrapper.createDiv(\"ai-sidebar-settings-provider__actions\");\n new ButtonComponent(actions)\n .setButtonText(this.plugin.settings.defaultProviderId === provider.id ? \"Default\" : \"Use\")\n .setDisabled(this.plugin.settings.defaultProviderId === provider.id)\n .onClick(async () => {\n this.plugin.settings.defaultProviderId = provider.id;\n await this.plugin.saveSettings();\n this.display();\n });\n this.configureConnectButton(new ButtonComponent(actions), provider);\n new ButtonComponent(actions)\n .setIcon(\"trash\")\n .setTooltip(\"Remove provider\")\n .onClick(async () => {\n this.plugin.settings.providers = this.plugin.settings.providers.filter((item) => item !== provider);\n if (this.plugin.settings.defaultProviderId === provider.id) {\n this.plugin.settings.defaultProviderId = this.plugin.settings.providers.first()?.id ?? \"\";\n }\n await this.plugin.saveSettings();\n this.display();\n });\n\n const fields = wrapper.createDiv(\"ai-sidebar-settings-provider__fields\");\n this.renderTextField(fields, \"Name\", provider.name, async (value) => {\n provider.name = value;\n await this.plugin.saveSettings();\n this.display();\n });\n\n if (provider.authType === \"cli\") {\n this.renderTextField(fields, \"Command\", provider.command, async (value) => {\n provider.command = value;\n await this.plugin.saveSettings();\n });\n if (provider.signInCommand) {\n this.renderTextField(fields, \"Sign-in command\", provider.signInCommand, async (value) => {\n provider.signInCommand = value;\n provider.connectedAt = undefined;\n await this.plugin.saveSettings();\n });\n }\n }\n\n if (provider.authType === \"api-key\") {\n this.renderTextField(fields, \"API key\", provider.apiKey ?? \"\", async (value) => {\n provider.apiKey = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n this.renderTextField(fields, \"Endpoint\", provider.apiBaseUrl ?? \"\", async (value) => {\n provider.apiBaseUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Default model\", provider.model ?? \"\", async (value) => {\n provider.model = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Slash models\", provider.models ?? \"\", async (value) => {\n provider.models = value;\n await this.plugin.saveSettings();\n });\n }\n\n if (provider.authType === \"oauth\") {\n this.renderTextField(fields, \"Authorization URL\", provider.authorizationUrl ?? \"\", async (value) => {\n provider.authorizationUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Client ID\", provider.clientId ?? \"\", async (value) => {\n provider.clientId = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Access token\", provider.accessToken ?? \"\", async (value) => {\n provider.accessToken = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n }\n }\n\n private renderTextField(\n containerEl: HTMLElement,\n label: string,\n value: string,\n onChange: (value: string) => Promise<void>,\n secret = false,\n ) {\n const field = containerEl.createDiv(\"ai-sidebar-settings-field\");\n field.createDiv({ cls: \"ai-sidebar-settings-field__label\", text: label });\n const input = field.createEl(\"input\", {\n cls: \"ai-sidebar-settings-field__input\",\n attr: { type: secret ? \"password\" : \"text\" },\n });\n input.value = value;\n input.addEventListener(\"change\", () => {\n void onChange(input.value);\n });\n }\n\n private providerDescription(provider: AgentProvider): string {\n if (provider.authType === \"api-key\") {\n return provider.apiKey ? \"Connected with API key.\" : \"Add an API key to connect.\";\n }\n if (provider.authType === \"oauth\") {\n return provider.accessToken ? \"Connected with OAuth.\" : \"Connect with OAuth when the provider exposes a desktop OAuth app.\";\n }\n return provider.command ? `Runs local command: ${provider.command}` : \"Add the local CLI command.\";\n }\n\n private configureConnectButton(button: ButtonComponent, provider: AgentProvider) {\n button\n .setButtonText(providerConnectionActionText(provider))\n .onClick(async () => {\n if (await startProviderSignIn(provider)) {\n provider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n this.display();\n }\n });\n }\n}\n\nclass ConfirmActionsModal extends Modal {\n private actions: VaultAction[];\n private resolve: (approved: boolean) => void;\n\n constructor(app: App, actions: VaultAction[], resolve: (approved: boolean) => void) {\n super(app);\n this.actions = actions;\n this.resolve = resolve;\n }\n\n onOpen() {\n const { contentEl } = this;\n contentEl.empty();\n contentEl.createEl(\"h2\", { text: \"Apply AI vault actions?\" });\n contentEl.createEl(\"p\", { text: \"Review these proposed file changes before they are applied.\" });\n\n for (const action of this.actions) {\n const actionEl = contentEl.createDiv(\"ai-sidebar-action-preview\");\n actionEl.createEl(\"strong\", { text: `${action.type}: ${action.path}` });\n if (action.type === \"rename\") {\n actionEl.createDiv({ text: `New path: ${action.newPath}` });\n }\n if (\"content\" in action) {\n actionEl.createEl(\"pre\", { text: action.content.slice(0, 1200) });\n }\n }\n\n new Setting(contentEl)\n .addButton((button) => {\n button.setButtonText(\"Cancel\").onClick(() => {\n this.resolve(false);\n this.close();\n });\n })\n .addButton((button) => {\n button.setCta().setButtonText(\"Apply\").onClick(() => {\n this.resolve(true);\n this.close();\n });\n });\n }\n\n onClose() {\n this.contentEl.empty();\n }\n}\n\nasync function collectVaultContext(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n selectedSkillNames: string[] = [],\n): Promise<VaultContext> {\n const activeView = app.workspace.getActiveViewOfType(MarkdownView);\n const activeFile = activeView?.file ?? app.workspace.getActiveFile();\n const selection = activeView?.editor.getSelection() || undefined;\n const budget = new ContextBudget(settings.maxContextChars);\n\n const context: VaultContext = {\n selection,\n linkedFiles: [],\n backlinkFiles: [],\n relevantFiles: [],\n baseFiles: [],\n selectedSkills: [],\n };\n\n const activeContext = activeFile ? await readContextFile(app, activeFile, \"active note\", budget) : undefined;\n if (activeContext) context.activeFile = activeContext;\n\n const linked = activeFile ? getLinkedFiles(app, activeFile) : [];\n for (const file of linked) {\n const item = await readContextFile(app, file, \"linked note\", budget);\n if (item) context.linkedFiles.push(item);\n }\n\n const backlinks = activeFile ? getBacklinkFiles(app, activeFile) : [];\n for (const file of backlinks) {\n const item = await readContextFile(app, file, \"backlink\", budget);\n if (item) context.backlinkFiles.push(item);\n }\n\n const baseFiles = app.vault\n .getFiles()\n .filter((file) => file.extension === \"base\" && shouldIncludeFile(file, settings))\n .slice(0, 5);\n for (const file of baseFiles) {\n const item = await readContextFile(app, file, \"base file\", budget);\n if (item) context.baseFiles.push(item);\n }\n\n const relatedFiles = await findRelevantFiles(app, settings, prompt, activeFile, budget.remaining());\n for (const file of relatedFiles) {\n const item = await readContextFile(app, file, \"relevant match\", budget);\n if (item) context.relevantFiles.push(item);\n }\n\n context.selectedSkills = await readSelectedSkills(app, selectedSkillNames, budget);\n\n return context;\n}\n\nasync function readContextFile(\n app: App,\n file: TFile,\n reason: string,\n budget: ContextBudget,\n): Promise<VaultContextFile | undefined> {\n if (!budget.hasRoom()) return undefined;\n const content = await app.vault.cachedRead(file);\n const clipped = budget.take(content);\n return { path: file.path, content: clipped, reason };\n}\n\nfunction getLinkedFiles(app: App, activeFile: TFile): TFile[] {\n const cache = app.metadataCache.getFileCache(activeFile);\n const links = cache?.links ?? [];\n const files: TFile[] = [];\n\n for (const link of links) {\n const linkedFile = app.metadataCache.getFirstLinkpathDest(link.link, activeFile.path);\n if (linkedFile) files.push(linkedFile);\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nfunction getBacklinkFiles(app: App, activeFile: TFile): TFile[] {\n const resolvedLinks = app.metadataCache.resolvedLinks;\n const files: TFile[] = [];\n\n for (const [sourcePath, targets] of Object.entries(resolvedLinks)) {\n if (targets[activeFile.path]) {\n const file = app.vault.getAbstractFileByPath(sourcePath);\n if (file instanceof TFile) files.push(file);\n }\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nasync function findRelevantFiles(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n activeFile: TFile | null,\n maxChars: number,\n): Promise<TFile[]> {\n if (!prompt.trim() || maxChars <= 0) return [];\n const terms = prompt\n .toLowerCase()\n .split(/[^a-z0-9/_-]+/i)\n .filter((term) => term.length > 2)\n .slice(0, 16);\n if (terms.length === 0) return [];\n\n const scored: Array<{ file: TFile; score: number }> = [];\n const files = app.vault\n .getFiles()\n .filter((file) => file !== activeFile && shouldIncludeFile(file, settings) && isReadableContextFile(file));\n\n for (const file of files) {\n const content = (await app.vault.cachedRead(file)).toLowerCase();\n const path = file.path.toLowerCase();\n let score = 0;\n for (const term of terms) {\n if (path.includes(term)) score += 4;\n const matches = content.match(new RegExp(escapeRegExp(term), \"g\"));\n score += matches ? Math.min(matches.length, 8) : 0;\n }\n if (score > 0) scored.push({ file, score });\n }\n\n return scored\n .sort((a, b) => b.score - a.score)\n .slice(0, 10)\n .map((item) => item.file);\n}\n\nfunction shouldIncludeFile(file: TFile, settings: AISidebarSettings): boolean {\n const includes = parseFolderList(settings.includeFolders);\n const excludes = parseFolderList(settings.excludeFolders);\n if (includes.length > 0 && !includes.some((folder) => file.path.startsWith(folder))) return false;\n if (excludes.some((folder) => file.path.startsWith(folder))) return false;\n return isReadableContextFile(file);\n}\n\nfunction isReadableContextFile(file: TFile): boolean {\n return [\"md\", \"canvas\", \"base\", \"json\", \"txt\"].includes(file.extension);\n}\n\nfunction nativeSlashCommands(\n provider: AgentProvider,\n query: string,\n currentAccessMode: AccessMode,\n): Array<{ label: string; description: string; insert: string }> {\n const commands: Array<{ label: string; description: string; insert: string }> = [];\n const modelChoices = parseProviderModels(provider);\n\n for (const model of modelChoices) {\n commands.push({\n label: `/model:${model}`,\n description: `Use ${model} for this request`,\n insert: `/model:${model}`,\n });\n }\n\n if (provider.authType === \"api-key\" || provider.id.includes(\"codex\") || provider.id.includes(\"openai\")) {\n for (const effort of [\"low\", \"medium\", \"high\"] as ReasoningEffort[]) {\n commands.push({\n label: `/reasoning:${effort}`,\n description: `Set reasoning effort to ${effort}`,\n insert: `/reasoning:${effort}`,\n });\n }\n }\n\n for (const mode of [\"read-only\", \"confirm\", \"full-access\"] as AccessMode[]) {\n commands.push({\n label: `/access:${mode}`,\n description: mode === currentAccessMode ? \"Current vault access mode\" : `Use ${mode} for this request`,\n insert: `/access:${mode}`,\n });\n }\n\n commands.push(\n { label: \"/memory:on\", description: \"Include remembered chat history for this request\", insert: \"/memory:on\" },\n { label: \"/memory:off\", description: \"Do not include remembered chat history for this request\", insert: \"/memory:off\" },\n );\n\n const normalizedQuery = query.toLowerCase();\n return commands.filter((command) => command.label.slice(1).toLowerCase().includes(normalizedQuery));\n}\n\nfunction parseProviderModels(provider: AgentProvider): string[] {\n const configured = provider.models\n ?.split(\",\")\n .map((model) => model.trim())\n .filter(Boolean) ?? [];\n const models = configured.length > 0 ? configured : [provider.model].filter((model): model is string => Boolean(model));\n return Array.from(new Set(models)).slice(0, 12);\n}\n\nasync function listAgentSkills(app: App, query = \"\"): Promise<SkillReference[]> {\n const normalizedQuery = query.toLowerCase();\n const vaultSkills = app.vault\n .getFiles()\n .filter((file) => isSkillFile(file))\n .map((file) => ({\n name: skillNameFromPath(file.path),\n path: file.path,\n source: \"vault\" as const,\n }));\n const adapterSkills = await listAgentSkillsFromAdapter(app);\n const globalSkills = await listGlobalAgentSkills();\n const skills = [...vaultSkills, ...adapterSkills, ...globalSkills]\n .filter((skill) => !normalizedQuery || skill.name.toLowerCase().includes(normalizedQuery))\n .sort((a, b) => a.name.localeCompare(b.name));\n\n const seen = new Set<string>();\n return skills.filter((skill) => {\n const key = `${skill.source}:${skill.name.toLowerCase()}`;\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\nasync function listAgentSkillsFromAdapter(app: App): Promise<SkillReference[]> {\n const roots = [\".agent/skills\", \".agents/skills\", \"agent/skills\", \"agents/skills\"];\n const files: string[] = [];\n for (const root of roots) {\n files.push(...await listAdapterFiles(app, root));\n }\n return files\n .filter((path) => isSkillPath(path))\n .map((path) => ({\n name: skillNameFromPath(path),\n path,\n source: \"vault\" as const,\n }));\n}\n\nasync function listGlobalAgentSkills(): Promise<SkillReference[]> {\n const roots = [\n path.join(homedir(), \".agent\", \"skills\"),\n path.join(homedir(), \".agents\", \"skills\"),\n ];\n const files: string[] = [];\n for (const root of roots) {\n files.push(...await listFilesystemFiles(root));\n }\n return files\n .filter((filePath) => isSkillPath(filePath))\n .map((filePath) => ({\n name: skillNameFromPath(filePath),\n path: filePath,\n source: \"global\" as const,\n }));\n}\n\nasync function listFilesystemFiles(folder: string): Promise<string[]> {\n try {\n const entries = await fs.readdir(folder, { withFileTypes: true });\n const results = await Promise.all(entries.map(async (entry) => {\n const childPath = path.join(folder, entry.name);\n if (entry.isDirectory()) return listFilesystemFiles(childPath);\n if (entry.isFile()) return [childPath];\n return [];\n }));\n return results.flat();\n } catch {\n return [];\n }\n}\n\nasync function listAdapterFiles(app: App, folder: string): Promise<string[]> {\n try {\n const listed = await app.vault.adapter.list(folder);\n const childFiles = listed.files;\n const nested = await Promise.all(listed.folders.map((child) => listAdapterFiles(app, child)));\n return [...childFiles, ...nested.flat()];\n } catch {\n return [];\n }\n}\n\nasync function readSelectedSkills(\n app: App,\n selectedSkillNames: string[],\n budget: ContextBudget,\n): Promise<AgentSkill[]> {\n const names = new Set(selectedSkillNames.map((name) => name.toLowerCase()));\n if (names.size === 0) return [];\n\n const skills: AgentSkill[] = [];\n const skillFiles = [...await listAgentSkillsFromAdapter(app), ...await listGlobalAgentSkills()];\n const vaultFiles = app.vault\n .getFiles()\n .filter((candidate) => isSkillFile(candidate))\n .map((file) => ({ name: skillNameFromPath(file.path), path: file.path, source: \"vault\" as const }));\n for (const skillFile of [...vaultFiles, ...skillFiles]) {\n const name = skillFile.name;\n if (!names.has(name.toLowerCase()) || !budget.hasRoom()) continue;\n const content = await readSkillContent(app, skillFile);\n skills.push({\n name,\n path: skillFile.path,\n content: budget.take(content),\n });\n }\n return skills;\n}\n\nasync function readSkillContent(app: App, skill: SkillReference): Promise<string> {\n if (skill.source === \"global\") return fs.readFile(skill.path, \"utf8\");\n const file = app.vault.getAbstractFileByPath(skill.path);\n if (file instanceof TFile) return app.vault.cachedRead(file);\n return app.vault.adapter.read(skill.path);\n}\n\nfunction isSkillFile(file: TFile): boolean {\n return isSkillPath(file.path);\n}\n\nfunction isSkillPath(path: string): boolean {\n const extension = path.split(\".\").last()?.toLowerCase() ?? \"\";\n if (![\"md\", \"txt\", \"json\", \"yaml\", \"yml\", \"prompt\"].includes(extension)) return false;\n const normalized = path.replace(/^\\/+/, \"\");\n return normalized.startsWith(\".agent/skills/\")\n || normalized.startsWith(\".agents/skills/\")\n || normalized.startsWith(\"agent/skills/\")\n || normalized.startsWith(\"agents/skills/\");\n}\n\nfunction skillNameFromPath(path: string): string {\n const parts = path.split(\"/\");\n const fileName = parts.last() ?? path;\n if (fileName.toLowerCase() === \"skill.md\" && parts.length > 1) {\n return parts[parts.length - 2];\n }\n return fileName.replace(/\\.[^.]+$/, \"\");\n}\n\nfunction parseFolderList(value: string): string[] {\n return value\n .split(\",\")\n .map((folder) => folder.trim().replace(/^\\/+/, \"\"))\n .filter(Boolean);\n}\n\nclass ContextBudget {\n private remainingChars: number;\n\n constructor(maxChars: number) {\n this.remainingChars = maxChars;\n }\n\n hasRoom(): boolean {\n return this.remainingChars > 0;\n }\n\n remaining(): number {\n return this.remainingChars;\n }\n\n take(content: string): string {\n const clipped = content.slice(0, this.remainingChars);\n this.remainingChars -= clipped.length;\n return clipped;\n }\n}\n\nfunction buildAgentInstructions(accessMode: AccessMode): string {\n return [\n \"You are running inside an Obsidian AI Sidebar plugin.\",\n \"Use the provided vault context to answer the user.\",\n \"If the prompt contains slash commands such as /writer or /reviewer, apply the matching selectedSkills instructions from the request context.\",\n \"The request may include conversation memory and per-request options such as model or reasoning effort.\",\n \"When you need file changes, include a JSON code block with this shape:\",\n '{\"actions\":[{\"type\":\"edit\",\"path\":\"Note.md\",\"content\":\"new content\"}]}',\n \"Allowed action types: create, edit, append, delete, rename.\",\n `Current access mode: ${accessMode}. The plugin enforces this mode before applying actions.`,\n ].join(\"\\n\");\n}\n\nasync function runProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n if (provider.authType === \"api-key\") {\n return runOpenAICompatibleProvider(provider, request);\n }\n\n if (provider.authType === \"oauth\") {\n if (!provider.accessToken) {\n throw new Error(\"This OAuth provider is not connected yet.\");\n }\n return runOAuthProvider(provider, request);\n }\n\n return runCliProvider(provider, request, app);\n}\n\nasync function runOpenAICompatibleProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiKey) throw new Error(\"Missing API key.\");\n const url = provider.apiBaseUrl || \"https://api.openai.com/v1/responses\";\n const model = request.options.model || provider.model || \"gpt-4.1-mini\";\n const userPayload = JSON.stringify({\n prompt: request.prompt,\n accessMode: request.accessMode,\n context: request.context,\n options: request.options,\n });\n const messages = [\n { role: \"system\", content: request.instructions },\n ...request.conversation.map((message) => ({\n role: message.role === \"assistant\" ? \"assistant\" : \"user\",\n content: message.content,\n })),\n { role: \"user\", content: userPayload },\n ];\n const usesChatCompletions = url.includes(\"/chat/completions\");\n const body: Record<string, unknown> = usesChatCompletions\n ? { model, messages }\n : { model, input: messages };\n if (request.options.reasoningEffort && model.startsWith(\"o\")) {\n body.reasoning = { effort: request.options.reasoningEffort };\n }\n const response = await requestUrl({\n url,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n return extractOpenAIText(response.json);\n}\n\nasync function runOAuthProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiBaseUrl) {\n throw new Error(\"OAuth provider needs an API endpoint after login.\");\n }\n const response = await requestUrl({\n url: provider.apiBaseUrl,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.accessToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(request),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n if (typeof response.json?.text === \"string\") return response.json.text;\n if (typeof response.json?.output_text === \"string\") return response.json.output_text;\n return response.text;\n}\n\nfunction runCliProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n return new Promise((resolve, reject) => {\n const prompt = cliPromptFromRequest(request);\n const command = cliCommandForRequest(provider, request, app);\n const shellCommand = `${command} ${shellQuote(prompt)}`;\n const child = spawn(\"/bin/zsh\", [\"-lc\", shellCommand], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n let stdout = \"\";\n let stderr = \"\";\n\n child.stdout.on(\"data\", (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) {\n resolve(stdout.trim());\n } else {\n reject(new Error(stderr.trim() || `Command exited with code ${code}. Check that ${command} is installed and available in your shell.`));\n }\n });\n\n });\n}\n\nfunction cliCommandForRequest(provider: AgentProvider, request: AgentRequest, app: App): string {\n const vaultPath = getVaultPath(app);\n if (provider.id === \"codex\" || provider.command.startsWith(\"codex exec\")) {\n const sandbox = providerSandboxForAccessMode(request.accessMode);\n const cdArg = vaultPath ? ` -C ${shellQuote(vaultPath)}` : \"\";\n return `${provider.command}${cdArg} --skip-git-repo-check -s ${sandbox}`;\n }\n return provider.command;\n}\n\nfunction providerSandboxForAccessMode(accessMode: AccessMode): string {\n if (accessMode === \"full-access\") return \"workspace-write\";\n return \"read-only\";\n}\n\nfunction getVaultPath(app: App): string | undefined {\n const adapter = app.vault.adapter;\n if (\"basePath\" in adapter && typeof adapter.basePath === \"string\") {\n return adapter.basePath;\n }\n return undefined;\n}\n\nfunction cliPromptFromRequest(request: AgentRequest): string {\n return [\n request.instructions,\n \"\",\n \"User request:\",\n request.prompt,\n \"\",\n \"Vault context and options:\",\n JSON.stringify({\n accessMode: request.accessMode,\n options: request.options,\n conversation: request.options.memoryEnabled ? request.conversation : [],\n context: request.context,\n }, null, 2),\n ].join(\"\\n\");\n}\n\nfunction openInteractiveTerminal(command: string, title: string): Promise<void> {\n const escapedCommand = escapeForSingleQuotedShell(command);\n const script = [\n `tell application \"Terminal\"`,\n \"activate\",\n `do script \"printf '\\\\\\\\033]0;${escapeForAppleScript(title)}\\\\\\\\007'; clear; echo 'AI Sidebar sign-in'; echo ''; echo 'Running: ${escapeForAppleScript(command)}'; echo ''; /bin/zsh -lc '${escapedCommand}; echo; echo Sign-in command finished. You can close this window when done.'\"`\n ,\n \"end tell\",\n ].join(\"\\n\");\n\n return new Promise((resolve, reject) => {\n const child = spawn(\"osascript\", [\"-e\", script], {\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n });\n let stderr = \"\";\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) resolve();\n else reject(new Error(stderr.trim() || `Could not open Terminal. Exit code ${code}`));\n });\n });\n}\n\nfunction extractOpenAIText(json: unknown): string {\n if (!json || typeof json !== \"object\") return \"\";\n const response = json as {\n output_text?: string;\n output?: Array<{ content?: Array<{ text?: string; type?: string }> }>;\n choices?: Array<{ message?: { content?: string } }>;\n };\n if (typeof response.output_text === \"string\") return response.output_text;\n if (typeof response.choices?.[0]?.message?.content === \"string\") {\n return response.choices[0].message.content;\n }\n const parts: string[] = [];\n for (const item of response.output ?? []) {\n for (const content of item.content ?? []) {\n if (typeof content.text === \"string\") parts.push(content.text);\n }\n }\n return parts.join(\"\\n\");\n}\n\nasync function startOAuthLogin(provider: AgentProvider): Promise<boolean> {\n if (!provider.authorizationUrl || !provider.clientId) {\n new Notice(\"Add an authorization URL and client ID first.\");\n return false;\n }\n\n const url = new URL(provider.authorizationUrl);\n url.searchParams.set(\"response_type\", \"token\");\n url.searchParams.set(\"client_id\", provider.clientId);\n url.searchParams.set(\"redirect_uri\", \"urn:ietf:wg:oauth:2.0:oob\");\n if (provider.scope) url.searchParams.set(\"scope\", provider.scope);\n url.searchParams.set(\"state\", crypto.randomUUID());\n\n window.open(url.toString());\n new Notice(\"OAuth sign-in opened in your browser. Paste the returned token into this provider when available.\");\n return true;\n}\n\nasync function startProviderSignIn(provider: AgentProvider): Promise<boolean> {\n if (provider.authType === \"cli\") {\n if (!provider.signInCommand) {\n new Notice(\"This local provider does not have a sign-in command configured.\");\n return false;\n }\n try {\n await openInteractiveTerminal(provider.signInCommand, `${provider.name} Sign In`);\n new Notice(`Opened Terminal for ${provider.name} sign-in.`);\n return true;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n new Notice(`Could not open sign-in: ${message}`);\n return false;\n }\n }\n\n if (provider.authType === \"api-key\") {\n if (provider.setupUrl) window.open(provider.setupUrl);\n new Notice(provider.apiKey ? `${provider.name} is connected.` : `Add your ${provider.name} API key below to connect.`);\n return Boolean(provider.apiKey);\n }\n\n return startOAuthLogin(provider);\n}\n\nfunction escapeForSingleQuotedShell(value: string): string {\n return value.replace(/'/g, \"'\\\\''\");\n}\n\nfunction shellQuote(value: string): string {\n return `'${escapeForSingleQuotedShell(value)}'`;\n}\n\nfunction escapeForAppleScript(value: string): string {\n return value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\nfunction providerConnectionActionText(provider: AgentProvider): string {\n if (provider.authType === \"cli\") return isProviderConnected(provider) ? \"Reconnect\" : \"Sign in\";\n if (provider.authType === \"api-key\") return provider.apiKey ? \"Connected\" : \"Get key\";\n return provider.accessToken ? \"Reconnect\" : \"Sign in\";\n}\n\nfunction providerConnectionLabel(provider: AgentProvider): string {\n if (isProviderConnected(provider)) return \"Connected\";\n if (provider.authType === \"api-key\") return \"Needs API key\";\n if (provider.authType === \"oauth\") return \"Needs sign-in\";\n return provider.signInCommand ? \"Ready to sign in\" : \"Needs command\";\n}\n\nfunction isProviderConnected(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken);\n return Boolean(provider.connectedAt);\n}\n\nfunction isProviderReady(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey && provider.apiBaseUrl);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken && provider.apiBaseUrl);\n return Boolean(provider.command.trim());\n}\n\nfunction normalizeProviders(providers: AgentProvider[]): AgentProvider[] {\n return providers.map((provider) => {\n const migrated = provider as AgentProvider;\n const command = migrated.id === \"codex\" && migrated.command === \"codex\"\n ? \"codex exec\"\n : migrated.command ?? \"\";\n return {\n ...migrated,\n authType: migrated.authType ?? (migrated.command ? \"cli\" : \"api-key\"),\n command,\n connectedAt: migrated.connectedAt,\n };\n });\n}\n\nfunction cloneProvider(provider: AgentProvider): AgentProvider {\n return { ...provider };\n}\n\nfunction extractVaultActions(response: string): VaultAction[] {\n const actions: VaultAction[] = [];\n const codeBlockPattern = /```(?:json)?\\s*([\\s\\S]*?)```/gi;\n const candidates: string[] = [];\n let match: RegExpExecArray | null;\n\n while ((match = codeBlockPattern.exec(response)) !== null) {\n candidates.push(match[1]);\n }\n candidates.push(response);\n\n for (const candidate of candidates) {\n try {\n const parsed = JSON.parse(candidate.trim()) as { actions?: VaultAction[] };\n if (Array.isArray(parsed.actions)) {\n actions.push(...parsed.actions.filter(isVaultAction));\n }\n } catch {\n continue;\n }\n }\n\n return actions;\n}\n\nfunction isVaultAction(value: unknown): value is VaultAction {\n if (!value || typeof value !== \"object\") return false;\n const action = value as Partial<VaultAction>;\n if (typeof action.type !== \"string\" || typeof action.path !== \"string\") return false;\n if ([\"create\", \"edit\", \"append\"].includes(action.type)) {\n return typeof (action as { content?: unknown }).content === \"string\";\n }\n if (action.type === \"delete\") return true;\n if (action.type === \"rename\") {\n return typeof (action as { newPath?: unknown }).newPath === \"string\";\n }\n return false;\n}\n\nfunction confirmVaultActions(app: App, actions: VaultAction[]): Promise<boolean> {\n return new Promise((resolve) => {\n new ConfirmActionsModal(app, actions, resolve).open();\n });\n}\n\nasync function applyVaultAction(app: App, action: VaultAction): Promise<void> {\n const path = normalizeVaultPath(action.path);\n const file = app.vault.getAbstractFileByPath(path);\n\n if (action.type === \"create\") {\n if (file) throw new Error(\"File already exists.\");\n await ensureParentFolder(app, path);\n await app.vault.create(path, action.content);\n return;\n }\n\n if (!(file instanceof TFile)) {\n throw new Error(\"File does not exist.\");\n }\n\n if (action.type === \"edit\") {\n await app.vault.modify(file, action.content);\n } else if (action.type === \"append\") {\n const current = await app.vault.read(file);\n await app.vault.modify(file, `${current}${current.endsWith(\"\\n\") ? \"\" : \"\\n\"}${action.content}`);\n } else if (action.type === \"delete\") {\n await app.vault.delete(file);\n } else if (action.type === \"rename\") {\n await ensureParentFolder(app, action.newPath);\n await app.fileManager.renameFile(file, normalizeVaultPath(action.newPath));\n }\n}\n\nasync function ensureParentFolder(app: App, path: string): Promise<void> {\n const parts = normalizeVaultPath(path).split(\"/\");\n parts.pop();\n let current = \"\";\n for (const part of parts) {\n current = current ? `${current}/${part}` : part;\n if (!app.vault.getAbstractFileByPath(current)) {\n await app.vault.createFolder(current);\n }\n }\n}\n\nfunction normalizeVaultPath(path: string): string {\n return path.replace(/^\\/+/, \"\").replace(/\\\\/g, \"/\");\n}\n\nfunction uniqueFiles(files: TFile[]): TFile[] {\n const seen = new Set<string>();\n return files.filter((file) => {\n if (seen.has(file.path)) return false;\n seen.add(file.path);\n return true;\n });\n}\n\nfunction sanitizeProviderId(value: string): string {\n return value.toLowerCase().replace(/[^a-z0-9_-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || \"provider\";\n}\n\nfunction isReasoningEffort(value: string): value is ReasoningEffort {\n return value === \"low\" || value === \"medium\" || value === \"high\";\n}\n\nfunction isAccessMode(value: string): value is AccessMode {\n return value === \"read-only\" || value === \"confirm\" || value === \"full-access\";\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"}, "4fca5b21a3129b730e796ed5179af5c65139481f": {"t": 1785759152.758069, "n": 61415, "h": "e90a33e4b92bcaf6", "c": "import {\n App,\n ButtonComponent,\n DropdownComponent,\n ItemView,\n MarkdownView,\n Modal,\n Notice,\n Plugin,\n PluginSettingTab,\n requestUrl,\n Setting,\n TFile,\n WorkspaceLeaf,\n} from \"obsidian\";\nimport { spawn } from \"child_process\";\n\nconst VIEW_TYPE_AI_SIDEBAR = \"ai-sidebar-view\";\n\ntype AccessMode = \"read-only\" | \"confirm\" | \"full-access\";\ntype ProviderAuthType = \"api-key\" | \"oauth\" | \"cli\";\ntype ReasoningEffort = \"low\" | \"medium\" | \"high\";\n\ninterface AgentProvider {\n id: string;\n name: string;\n authType: ProviderAuthType;\n command: string;\n signInCommand?: string;\n setupUrl?: string;\n connectedAt?: number;\n apiBaseUrl?: string;\n apiKey?: string;\n model?: string;\n models?: string;\n reasoningEffort?: ReasoningEffort;\n authorizationUrl?: string;\n tokenUrl?: string;\n clientId?: string;\n scope?: string;\n accessToken?: string;\n refreshToken?: string;\n tokenExpiresAt?: number;\n}\n\ninterface AISidebarSettings {\n providers: AgentProvider[];\n defaultProviderId: string;\n defaultAccessMode: AccessMode;\n includeFolders: string;\n excludeFolders: string;\n maxContextChars: number;\n enableConversationMemory: boolean;\n maxMemoryMessages: number;\n rememberedMessages: ChatMessage[];\n}\n\ninterface VaultContextFile {\n path: string;\n content: string;\n reason: string;\n}\n\ninterface AgentSkill {\n name: string;\n path: string;\n content: string;\n}\n\ninterface VaultContext {\n activeFile?: VaultContextFile;\n selection?: string;\n linkedFiles: VaultContextFile[];\n backlinkFiles: VaultContextFile[];\n relevantFiles: VaultContextFile[];\n baseFiles: VaultContextFile[];\n selectedSkills: AgentSkill[];\n}\n\ninterface ChatMessage {\n role: \"user\" | \"assistant\" | \"system\";\n content: string;\n}\n\ninterface AgentRequest {\n prompt: string;\n accessMode: AccessMode;\n context: VaultContext;\n instructions: string;\n conversation: ChatMessage[];\n options: AgentRequestOptions;\n}\n\ninterface AgentRequestOptions {\n model?: string;\n reasoningEffort?: ReasoningEffort;\n memoryEnabled: boolean;\n}\n\ninterface ParsedPrompt {\n cleanPrompt: string;\n selectedSkillNames: string[];\n options: AgentRequestOptions;\n accessMode?: AccessMode;\n}\n\ntype VaultAction =\n | { type: \"create\"; path: string; content: string }\n | { type: \"edit\"; path: string; content: string }\n | { type: \"append\"; path: string; content: string }\n | { type: \"delete\"; path: string }\n | { type: \"rename\"; path: string; newPath: string };\n\ninterface ProviderPreset {\n id: string;\n name: string;\n description: string;\n provider: AgentProvider;\n}\n\nconst PROVIDER_PRESETS: ProviderPreset[] = [\n {\n id: \"codex\",\n name: \"Codex\",\n description: \"Sign in with Codex locally and use the Codex CLI agent.\",\n provider: {\n id: \"codex\",\n name: \"Codex\",\n authType: \"cli\",\n command: \"codex exec\",\n signInCommand: \"codex login\",\n models: \"gpt-5.4, gpt-5.4-mini, gpt-5.3-codex\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"claude\",\n name: \"Claude Code\",\n description: \"Sign in with your Claude subscription or Anthropic Console account.\",\n provider: {\n id: \"claude\",\n name: \"Claude Code\",\n authType: \"cli\",\n command: \"claude\",\n signInCommand: \"claude auth login\",\n setupUrl: \"https://code.claude.com/docs/en/cli-usage\",\n models: \"opus, sonnet, haiku\",\n },\n },\n {\n id: \"opencode\",\n name: \"opencode\",\n description: \"Connect opencode to any provider it supports, including subscription/API accounts.\",\n provider: {\n id: \"opencode\",\n name: \"opencode\",\n authType: \"cli\",\n command: \"opencode\",\n signInCommand: \"opencode auth login\",\n setupUrl: \"https://opencode.ai/docs/cli/\",\n models: \"anthropic/claude-sonnet-4-5, openai/gpt-5.4, google/gemini-2.5-pro\",\n },\n },\n {\n id: \"openai\",\n name: \"OpenAI API\",\n description: \"Use an OpenAI API key with the Responses API.\",\n provider: {\n id: \"openai\",\n name: \"OpenAI\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.openai.com/api-keys\",\n apiBaseUrl: \"https://api.openai.com/v1/responses\",\n model: \"gpt-4.1-mini\",\n models: \"gpt-4.1-mini, gpt-4.1, o4-mini\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n description: \"Use an Anthropic API key directly through an OpenAI-compatible proxy endpoint.\",\n provider: {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.anthropic.com/settings/keys\",\n apiBaseUrl: \"\",\n model: \"claude-sonnet-4-5\",\n models: \"claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5\",\n },\n },\n {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n description: \"Use Google Gemini CLI with its native sign-in and subscription/API setup.\",\n provider: {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n authType: \"cli\",\n command: \"gemini\",\n signInCommand: \"gemini\",\n setupUrl: \"https://google-gemini.github.io/gemini-cli/docs/\",\n models: \"gemini-2.5-pro, gemini-2.5-flash\",\n },\n },\n {\n id: \"openrouter\",\n name: \"OpenRouter\",\n description: \"Use one API key for many model providers through an OpenAI-compatible endpoint.\",\n provider: {\n id: \"openrouter\",\n name: \"OpenRouter\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://openrouter.ai/settings/keys\",\n apiBaseUrl: \"https://openrouter.ai/api/v1/chat/completions\",\n model: \"openai/gpt-4.1-mini\",\n models: \"openai/gpt-4.1-mini, anthropic/claude-sonnet-4.5, google/gemini-2.5-pro\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"deepseek\",\n name: \"DeepSeek\",\n description: \"Use DeepSeek with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.deepseek.com/api_keys\",\n apiBaseUrl: \"https://api.deepseek.com/v1/chat/completions\",\n model: \"deepseek-chat\",\n models: \"deepseek-chat, deepseek-reasoner\",\n },\n },\n {\n id: \"mistral\",\n name: \"Mistral\",\n description: \"Use Mistral models with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"mistral\",\n name: \"Mistral\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.mistral.ai/api-keys\",\n apiBaseUrl: \"https://api.mistral.ai/v1/chat/completions\",\n model: \"mistral-large-latest\",\n models: \"mistral-large-latest, codestral-latest, ministral-8b-latest\",\n },\n },\n {\n id: \"aider\",\n name: \"Aider\",\n description: \"Use Aider with keys from OpenAI, Anthropic, Gemini, OpenRouter, DeepSeek, and more.\",\n provider: {\n id: \"aider\",\n name: \"Aider\",\n authType: \"cli\",\n command: \"aider\",\n signInCommand: \"aider\",\n setupUrl: \"https://aider.chat/docs/config/api-keys.html\",\n models: \"sonnet, opus, gpt-4.1, o4-mini, gemini/gemini-2.5-pro\",\n },\n },\n];\n\nconst DEFAULT_SETTINGS: AISidebarSettings = {\n providers: [\n cloneProvider(PROVIDER_PRESETS[0].provider),\n cloneProvider(PROVIDER_PRESETS[3].provider),\n ],\n defaultProviderId: \"codex\",\n defaultAccessMode: \"confirm\",\n includeFolders: \"\",\n excludeFolders: \".obsidian, node_modules, .git\",\n maxContextChars: 45000,\n enableConversationMemory: true,\n maxMemoryMessages: 30,\n rememberedMessages: [],\n};\n\nexport default class AISidebarPlugin extends Plugin {\n settings: AISidebarSettings;\n\n async onload() {\n await this.loadSettings();\n\n this.registerView(\n VIEW_TYPE_AI_SIDEBAR,\n (leaf) => new AISidebarView(leaf, this),\n );\n\n this.addRibbonIcon(\"sparkles\", \"Toggle AI Sidebar\", () => {\n void this.toggleSidebar();\n });\n\n this.addCommand({\n id: \"toggle-ai-sidebar\",\n name: \"Toggle AI Sidebar\",\n hotkeys: [{ modifiers: [\"Mod\", \"Shift\"], key: \"A\" }],\n callback: () => {\n void this.toggleSidebar();\n },\n });\n\n this.addSettingTab(new AISidebarSettingTab(this.app, this));\n }\n\n onunload() {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n }\n\n async toggleSidebar() {\n const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AI_SIDEBAR).first();\n if (existingLeaf) {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n return;\n }\n\n const leaf = this.app.workspace.getRightLeaf(false);\n if (!leaf) {\n new Notice(\"Could not open the AI sidebar.\");\n return;\n }\n\n await leaf.setViewState({ type: VIEW_TYPE_AI_SIDEBAR, active: true });\n this.app.workspace.revealLeaf(leaf);\n }\n\n async loadSettings() {\n const loaded = await this.loadData();\n this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);\n this.settings.providers = normalizeProviders(this.settings.providers);\n this.settings.rememberedMessages = Array.isArray(this.settings.rememberedMessages)\n ? this.settings.rememberedMessages\n : [];\n }\n\n async saveSettings() {\n await this.saveData(this.settings);\n }\n\n getProvider(id: string): AgentProvider | undefined {\n return this.settings.providers.find((provider) => provider.id === id);\n }\n\n async rememberMessages(messages: ChatMessage[]) {\n if (!this.settings.enableConversationMemory) return;\n this.settings.rememberedMessages = messages.slice(-this.settings.maxMemoryMessages);\n await this.saveSettings();\n }\n\n async clearMemory() {\n this.settings.rememberedMessages = [];\n await this.saveSettings();\n }\n\n async upsertProviderFromPreset(preset: ProviderPreset) {\n const provider = cloneProvider(preset.provider);\n const existingIndex = this.settings.providers.findIndex((item) => item.id === provider.id);\n if (existingIndex >= 0) {\n this.settings.providers[existingIndex] = {\n ...provider,\n apiKey: this.settings.providers[existingIndex].apiKey,\n accessToken: this.settings.providers[existingIndex].accessToken,\n refreshToken: this.settings.providers[existingIndex].refreshToken,\n };\n } else {\n this.settings.providers.push(provider);\n }\n this.settings.defaultProviderId = provider.id;\n await this.saveSettings();\n }\n}\n\nclass AISidebarView extends ItemView {\n private plugin: AISidebarPlugin;\n private messages: ChatMessage[] = [];\n private providerId: string;\n private accessMode: AccessMode;\n private threadEl: HTMLElement;\n private textareaEl: HTMLTextAreaElement;\n private contextEl: HTMLElement;\n private slashEl: HTMLElement;\n private activeContextPath = \"\";\n private refreshTimer: number | undefined;\n private sendButton: ButtonComponent;\n private isRunning = false;\n\n constructor(leaf: WorkspaceLeaf, plugin: AISidebarPlugin) {\n super(leaf);\n this.plugin = plugin;\n this.messages = plugin.settings.enableConversationMemory\n ? [...plugin.settings.rememberedMessages]\n : [];\n this.providerId = plugin.settings.defaultProviderId;\n this.accessMode = plugin.settings.defaultAccessMode;\n }\n\n getViewType(): string {\n return VIEW_TYPE_AI_SIDEBAR;\n }\n\n getDisplayText(): string {\n return \"AI Sidebar\";\n }\n\n getIcon(): string {\n return \"sparkles\";\n }\n\n async onOpen() {\n this.render();\n this.registerEvent(\n this.app.workspace.on(\"active-leaf-change\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.workspace.on(\"file-open\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.vault.on(\"modify\", (file) => {\n if (file instanceof TFile && file.path === this.activeContextPath) {\n this.scheduleContextRefresh();\n }\n }),\n );\n await this.refreshContextPreview();\n }\n\n async onClose() {\n this.contentEl.empty();\n }\n\n private render() {\n this.contentEl.empty();\n this.contentEl.addClass(\"ai-sidebar\");\n\n const header = this.contentEl.createDiv(\"ai-sidebar__header\");\n header.createDiv({ cls: \"ai-sidebar__title\", text: \"AI Sidebar\" });\n header.createDiv({ cls: \"ai-sidebar__subtitle\", text: \"Vault-aware agents\" });\n\n const controls = this.contentEl.createDiv(\"ai-sidebar__controls\");\n new DropdownComponent(controls)\n .addOptions(this.providerOptions())\n .setValue(this.providerId)\n .onChange((value) => {\n this.providerId = value;\n });\n\n new DropdownComponent(controls)\n .addOptions({\n \"read-only\": \"Read only\",\n confirm: \"Confirm actions\",\n \"full-access\": \"Full access\",\n })\n .setValue(this.accessMode)\n .onChange((value: AccessMode) => {\n this.accessMode = value;\n void this.refreshContextPreview();\n });\n\n this.contextEl = this.contentEl.createDiv(\"ai-sidebar__context\");\n\n this.threadEl = this.contentEl.createDiv(\"ai-sidebar__thread\");\n this.renderMessages();\n\n const composer = this.contentEl.createDiv(\"ai-sidebar__composer\");\n this.textareaEl = composer.createEl(\"textarea\", {\n cls: \"ai-sidebar__input\",\n attr: {\n placeholder: \"Ask about your vault, draft a note, or request an edit...\",\n rows: \"4\",\n },\n });\n\n this.textareaEl.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Escape\" && !this.slashEl.hasClass(\"is-hidden\")) {\n event.preventDefault();\n this.hideSlashCommands();\n return;\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n void this.sendPrompt();\n }\n });\n this.textareaEl.addEventListener(\"input\", () => {\n void this.updateSlashCommands();\n });\n\n this.slashEl = composer.createDiv(\"ai-sidebar__slash is-hidden\");\n\n const actions = composer.createDiv(\"ai-sidebar__composer-actions\");\n new ButtonComponent(actions)\n .setButtonText(\"Refresh context\")\n .onClick(() => {\n void this.refreshContextPreview();\n });\n new ButtonComponent(actions)\n .setButtonText(\"Clear\")\n .onClick(() => {\n void this.clearConversation();\n });\n\n this.sendButton = new ButtonComponent(actions)\n .setCta()\n .setButtonText(\"Send\")\n .onClick(() => {\n void this.sendPrompt();\n });\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderMessages() {\n this.threadEl.empty();\n\n if (this.messages.length === 0) {\n const empty = this.threadEl.createDiv(\"ai-sidebar__empty\");\n empty.createDiv({ text: \"Ask a local agent to work with the notes in this vault.\" });\n empty.createDiv({ text: \"Use confirm mode when you want to review file edits first.\" });\n return;\n }\n\n for (const message of this.messages) {\n const messageEl = this.threadEl.createDiv(`ai-sidebar__message ai-sidebar__message--${message.role}`);\n messageEl.createDiv({ cls: \"ai-sidebar__message-role\", text: message.role });\n messageEl.createEl(\"pre\", { text: message.content });\n }\n\n this.threadEl.scrollTo({ top: this.threadEl.scrollHeight });\n }\n\n private async refreshContextPreview(prompt = \"\") {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n prompt,\n this.extractSelectedSkillNames(prompt),\n );\n this.activeContextPath = context.activeFile?.path ?? \"\";\n this.contextEl.empty();\n this.contextEl.createDiv({\n cls: \"ai-sidebar__context-title\",\n text: `Context preview · ${this.accessModeLabel()}`,\n });\n\n const items = [\n context.activeFile ? `Active: ${context.activeFile.path}` : \"No active note\",\n context.selection ? \"Selection included\" : \"No selection\",\n `${context.linkedFiles.length} linked`,\n `${context.backlinkFiles.length} backlinks`,\n `${context.relevantFiles.length} relevant`,\n `${context.baseFiles.length} bases`,\n `${context.selectedSkills.length} skills`,\n ];\n\n const list = this.contextEl.createEl(\"ul\");\n for (const item of items) {\n list.createEl(\"li\", { text: item });\n }\n }\n\n private scheduleContextRefresh() {\n window.clearTimeout(this.refreshTimer);\n this.refreshTimer = window.setTimeout(() => {\n void this.refreshContextPreview(this.textareaEl?.value ?? \"\");\n }, 150);\n }\n\n private accessModeLabel(): string {\n if (this.accessMode === \"read-only\") return \"read only\";\n if (this.accessMode === \"full-access\") return \"full access\";\n return \"confirm actions\";\n }\n\n private async sendPrompt() {\n const rawPrompt = this.textareaEl.value.trim();\n if (!rawPrompt || this.isRunning) return;\n\n const provider = this.plugin.getProvider(this.providerId);\n if (!provider) {\n new Notice(\"Choose a provider in AI Sidebar settings.\");\n return;\n }\n\n if (!isProviderReady(provider)) {\n new Notice(\"Connect or configure this provider in AI Sidebar settings first.\");\n return;\n }\n\n this.isRunning = true;\n this.sendButton.setButtonText(\"Running...\");\n this.textareaEl.value = \"\";\n const parsed = this.parsePromptControls(rawPrompt, provider);\n const effectiveAccessMode = parsed.accessMode ?? this.accessMode;\n this.messages.push({ role: \"user\", content: rawPrompt });\n this.renderMessages();\n\n try {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n parsed.cleanPrompt,\n parsed.selectedSkillNames,\n );\n await this.refreshContextPreview(rawPrompt);\n const request: AgentRequest = {\n prompt: parsed.cleanPrompt,\n accessMode: effectiveAccessMode,\n context,\n instructions: buildAgentInstructions(effectiveAccessMode),\n conversation: this.plugin.settings.enableConversationMemory ? this.messages.slice(0, -1) : [],\n options: parsed.options,\n };\n\n const response = await runProvider(provider, request, this.app);\n const actionResult = await this.handleActions(response);\n const assistantContent = actionResult ? `${response}\\n\\n${actionResult}` : response;\n this.messages.push({ role: \"assistant\", content: assistantContent.trim() || \"No response.\" });\n await this.plugin.rememberMessages(this.messages);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n this.messages.push({ role: \"system\", content: `Provider failed: ${message}` });\n await this.plugin.rememberMessages(this.messages);\n new Notice(\"AI provider failed. Check the sidebar for details.\");\n } finally {\n this.isRunning = false;\n this.sendButton.setButtonText(\"Send\");\n this.renderMessages();\n }\n }\n\n private async clearConversation() {\n this.messages = [];\n await this.plugin.clearMemory();\n this.renderMessages();\n new Notice(\"AI Sidebar memory cleared.\");\n }\n\n private async handleActions(response: string): Promise<string> {\n const actions = extractVaultActions(response);\n if (actions.length === 0) return \"\";\n\n if (this.accessMode === \"read-only\") {\n return \"Vault actions were proposed, but read-only mode blocked all writes.\";\n }\n\n if (this.accessMode === \"confirm\") {\n const approved = await confirmVaultActions(this.app, actions);\n if (!approved) return \"Vault actions were not applied.\";\n }\n\n const results: string[] = [];\n for (const action of actions) {\n try {\n await applyVaultAction(this.app, action);\n results.push(`Applied ${action.type}: ${action.path}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n results.push(`Failed ${action.type}: ${action.path} · ${message}`);\n }\n }\n return results.join(\"\\n\");\n }\n\n private async updateSlashCommands() {\n const query = this.currentSlashQuery();\n if (query === null) {\n this.hideSlashCommands();\n return;\n }\n\n const provider = this.plugin.getProvider(this.providerId);\n const commands = provider ? nativeSlashCommands(provider, query, this.accessMode) : [];\n const skills = await listAgentSkills(this.plugin.app, query);\n this.slashEl.empty();\n\n if (commands.length === 0 && skills.length === 0) {\n this.hideSlashCommands();\n return;\n }\n\n this.slashEl.removeClass(\"is-hidden\");\n for (const command of commands.slice(0, 10)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: command.label });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: command.description });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(command.insert);\n });\n }\n for (const skill of skills.slice(0, 8)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: `/${skill.name}` });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: skill.path });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(`/${skill.name}`);\n });\n }\n }\n\n private hideSlashCommands() {\n this.slashEl.empty();\n this.slashEl.addClass(\"is-hidden\");\n }\n\n private currentSlashQuery(): string | null {\n const cursor = this.textareaEl.selectionStart;\n const beforeCursor = this.textareaEl.value.slice(0, cursor);\n const match = beforeCursor.match(/(?:^|\\s)\\/([A-Za-z0-9_.-]*)$/);\n return match ? match[1].toLowerCase() : null;\n }\n\n private insertSlashToken(token: string) {\n const cursor = this.textareaEl.selectionStart;\n const value = this.textareaEl.value;\n const beforeCursor = value.slice(0, cursor);\n const afterCursor = value.slice(cursor);\n const replaced = beforeCursor.replace(/(?:^|\\s)\\/([A-Za-z0-9_.-]*)$/, (match) => {\n const prefix = match.startsWith(\" \") ? \" \" : \"\";\n return `${prefix}${token} `;\n });\n this.textareaEl.value = `${replaced}${afterCursor}`;\n this.textareaEl.focus();\n this.textareaEl.selectionStart = replaced.length;\n this.textareaEl.selectionEnd = replaced.length;\n this.hideSlashCommands();\n this.scheduleContextRefresh();\n }\n\n private extractSelectedSkillNames(prompt: string): string[] {\n const native = new Set([\"model\", \"reasoning\", \"access\", \"memory\"]);\n return Array.from(prompt.matchAll(/(?:^|\\s)\\/([A-Za-z0-9_.-]+)/g), (match) => match[1])\n .filter((name) => !native.has(name.split(\":\")[0].toLowerCase()));\n }\n\n private parsePromptControls(prompt: string, provider: AgentProvider): ParsedPrompt {\n const options: AgentRequestOptions = {\n model: provider.model,\n reasoningEffort: provider.reasoningEffort,\n memoryEnabled: this.plugin.settings.enableConversationMemory,\n };\n let accessMode: AccessMode | undefined;\n\n const cleanPrompt = prompt.replace(/(?:^|\\s)\\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match, key, value) => {\n const normalizedKey = String(key).toLowerCase();\n const normalizedValue = String(value).toLowerCase();\n if (normalizedKey === \"model\") {\n options.model = value;\n } else if (normalizedKey === \"reasoning\" && isReasoningEffort(normalizedValue)) {\n options.reasoningEffort = normalizedValue;\n } else if (normalizedKey === \"access\" && isAccessMode(normalizedValue)) {\n accessMode = normalizedValue;\n } else if (normalizedKey === \"memory\") {\n options.memoryEnabled = normalizedValue === \"on\";\n }\n return match.startsWith(\" \") ? \" \" : \"\";\n }).trim();\n\n return {\n cleanPrompt: cleanPrompt || prompt,\n selectedSkillNames: this.extractSelectedSkillNames(prompt),\n options,\n accessMode,\n };\n }\n}\n\nclass AISidebarSettingTab extends PluginSettingTab {\n plugin: AISidebarPlugin;\n\n constructor(app: App, plugin: AISidebarPlugin) {\n super(app, plugin);\n this.plugin = plugin;\n }\n\n display(): void {\n const { containerEl } = this;\n containerEl.empty();\n containerEl.createEl(\"h2\", { text: \"AI Sidebar\" });\n\n new Setting(containerEl)\n .setName(\"Default provider\")\n .setDesc(\"The AI provider used when the sidebar opens.\")\n .addDropdown((dropdown) => {\n dropdown.addOptions(this.providerOptions());\n dropdown.setValue(this.plugin.settings.defaultProviderId);\n dropdown.onChange(async (value) => {\n this.plugin.settings.defaultProviderId = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Default vault access\")\n .setDesc(\"How much permission the agent has when it proposes changes.\")\n .addDropdown((dropdown) => {\n dropdown\n .addOption(\"read-only\", \"Read only\")\n .addOption(\"confirm\", \"Confirm actions\")\n .addOption(\"full-access\", \"Full access\")\n .setValue(this.plugin.settings.defaultAccessMode)\n .onChange(async (value: AccessMode) => {\n this.plugin.settings.defaultAccessMode = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Include folders\")\n .setDesc(\"Comma-separated folder prefixes to include. Leave blank for the whole vault.\")\n .addText((text) => {\n text\n .setPlaceholder(\"Projects, Daily Notes\")\n .setValue(this.plugin.settings.includeFolders)\n .onChange(async (value) => {\n this.plugin.settings.includeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Exclude folders\")\n .setDesc(\"Comma-separated folder prefixes that should never be sent to an agent.\")\n .addText((text) => {\n text\n .setValue(this.plugin.settings.excludeFolders)\n .onChange(async (value) => {\n this.plugin.settings.excludeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Max context characters\")\n .setDesc(\"Upper bound for note text sent to the agent.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxContextChars))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed > 1000) {\n this.plugin.settings.maxContextChars = parsed;\n await this.plugin.saveSettings();\n }\n });\n });\n\n new Setting(containerEl)\n .setName(\"Conversation memory\")\n .setDesc(\"Remember previous sidebar messages after closing and reopening Obsidian.\")\n .addToggle((toggle) => {\n toggle\n .setValue(this.plugin.settings.enableConversationMemory)\n .onChange(async (value) => {\n this.plugin.settings.enableConversationMemory = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Memory length\")\n .setDesc(\"Maximum number of recent sidebar messages to remember.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxMemoryMessages))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed >= 2) {\n this.plugin.settings.maxMemoryMessages = parsed;\n await this.plugin.saveSettings();\n }\n });\n })\n .addButton((button) => {\n button.setButtonText(\"Clear memory\").onClick(async () => {\n await this.plugin.clearMemory();\n new Notice(\"AI Sidebar memory cleared.\");\n });\n });\n\n containerEl.createEl(\"h3\", { text: \"Connect a Provider\" });\n this.renderProviderPresets(containerEl);\n\n containerEl.createEl(\"h3\", { text: \"Connected Providers\" });\n for (const provider of this.plugin.settings.providers) {\n this.renderProviderSetting(containerEl, provider);\n }\n\n new Setting(containerEl)\n .setName(\"Advanced provider\")\n .setDesc(\"Create a custom API, OAuth, or local CLI provider.\")\n .addButton((button) => {\n button.setButtonText(\"Add custom\").onClick(async () => {\n const id = `provider-${Date.now()}`;\n this.plugin.settings.providers.push({ id, name: \"New provider\", authType: \"oauth\", command: \"\" });\n await this.plugin.saveSettings();\n this.display();\n });\n });\n }\n\n private renderProviderPresets(containerEl: HTMLElement) {\n const grid = containerEl.createDiv(\"ai-sidebar-provider-grid\");\n for (const preset of PROVIDER_PRESETS) {\n const provider = this.plugin.getProvider(preset.provider.id);\n const card = grid.createDiv(\"ai-sidebar-provider-card\");\n card.createDiv({ cls: \"ai-sidebar-provider-card__name\", text: preset.name });\n card.createDiv({ cls: \"ai-sidebar-provider-card__desc\", text: preset.description });\n const status = provider ? providerConnectionLabel(provider) : \"Not added\";\n card.createDiv({ cls: \"ai-sidebar-provider-card__status\", text: status });\n\n const actions = card.createDiv(\"ai-sidebar-provider-card__actions\");\n new ButtonComponent(actions)\n .setButtonText(provider ? \"Use\" : \"Add\")\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n this.display();\n });\n\n new ButtonComponent(actions)\n .setCta()\n .setButtonText(providerConnectionActionText(provider ?? preset.provider))\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n const connectedProvider = this.plugin.getProvider(preset.provider.id) ?? preset.provider;\n if (await startProviderSignIn(connectedProvider)) {\n connectedProvider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n }\n this.display();\n });\n }\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderProviderSetting(containerEl: HTMLElement, provider: AgentProvider) {\n const wrapper = containerEl.createDiv(\"ai-sidebar-settings-provider\");\n const header = wrapper.createDiv(\"ai-sidebar-settings-provider__header\");\n const title = header.createDiv(\"ai-sidebar-settings-provider__title\");\n title.createDiv({ cls: \"ai-sidebar-settings-provider__name\", text: provider.name || provider.id });\n title.createDiv({ cls: \"ai-sidebar-settings-provider__desc\", text: this.providerDescription(provider) });\n header.createSpan({\n cls: `ai-sidebar-settings-provider__badge ${isProviderConnected(provider) ? \"is-connected\" : \"\"}`,\n text: providerConnectionLabel(provider),\n });\n\n const actions = wrapper.createDiv(\"ai-sidebar-settings-provider__actions\");\n new ButtonComponent(actions)\n .setButtonText(this.plugin.settings.defaultProviderId === provider.id ? \"Default\" : \"Use\")\n .setDisabled(this.plugin.settings.defaultProviderId === provider.id)\n .onClick(async () => {\n this.plugin.settings.defaultProviderId = provider.id;\n await this.plugin.saveSettings();\n this.display();\n });\n this.configureConnectButton(new ButtonComponent(actions), provider);\n new ButtonComponent(actions)\n .setIcon(\"trash\")\n .setTooltip(\"Remove provider\")\n .onClick(async () => {\n this.plugin.settings.providers = this.plugin.settings.providers.filter((item) => item !== provider);\n if (this.plugin.settings.defaultProviderId === provider.id) {\n this.plugin.settings.defaultProviderId = this.plugin.settings.providers.first()?.id ?? \"\";\n }\n await this.plugin.saveSettings();\n this.display();\n });\n\n const fields = wrapper.createDiv(\"ai-sidebar-settings-provider__fields\");\n this.renderTextField(fields, \"Name\", provider.name, async (value) => {\n provider.name = value;\n await this.plugin.saveSettings();\n this.display();\n });\n\n if (provider.authType === \"cli\") {\n this.renderTextField(fields, \"Command\", provider.command, async (value) => {\n provider.command = value;\n await this.plugin.saveSettings();\n });\n if (provider.signInCommand) {\n this.renderTextField(fields, \"Sign-in command\", provider.signInCommand, async (value) => {\n provider.signInCommand = value;\n provider.connectedAt = undefined;\n await this.plugin.saveSettings();\n });\n }\n }\n\n if (provider.authType === \"api-key\") {\n this.renderTextField(fields, \"API key\", provider.apiKey ?? \"\", async (value) => {\n provider.apiKey = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n this.renderTextField(fields, \"Endpoint\", provider.apiBaseUrl ?? \"\", async (value) => {\n provider.apiBaseUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Default model\", provider.model ?? \"\", async (value) => {\n provider.model = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Slash models\", provider.models ?? \"\", async (value) => {\n provider.models = value;\n await this.plugin.saveSettings();\n });\n }\n\n if (provider.authType === \"oauth\") {\n this.renderTextField(fields, \"Authorization URL\", provider.authorizationUrl ?? \"\", async (value) => {\n provider.authorizationUrl = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Client ID\", provider.clientId ?? \"\", async (value) => {\n provider.clientId = value;\n await this.plugin.saveSettings();\n });\n this.renderTextField(fields, \"Access token\", provider.accessToken ?? \"\", async (value) => {\n provider.accessToken = value;\n provider.connectedAt = value ? Date.now() : undefined;\n await this.plugin.saveSettings();\n this.display();\n }, true);\n }\n }\n\n private renderTextField(\n containerEl: HTMLElement,\n label: string,\n value: string,\n onChange: (value: string) => Promise<void>,\n secret = false,\n ) {\n const field = containerEl.createDiv(\"ai-sidebar-settings-field\");\n field.createDiv({ cls: \"ai-sidebar-settings-field__label\", text: label });\n const input = field.createEl(\"input\", {\n cls: \"ai-sidebar-settings-field__input\",\n attr: { type: secret ? \"password\" : \"text\" },\n });\n input.value = value;\n input.addEventListener(\"change\", () => {\n void onChange(input.value);\n });\n }\n\n private providerDescription(provider: AgentProvider): string {\n if (provider.authType === \"api-key\") {\n return provider.apiKey ? \"Connected with API key.\" : \"Add an API key to connect.\";\n }\n if (provider.authType === \"oauth\") {\n return provider.accessToken ? \"Connected with OAuth.\" : \"Connect with OAuth when the provider exposes a desktop OAuth app.\";\n }\n return provider.command ? `Runs local command: ${provider.command}` : \"Add the local CLI command.\";\n }\n\n private configureConnectButton(button: ButtonComponent, provider: AgentProvider) {\n button\n .setButtonText(providerConnectionActionText(provider))\n .onClick(async () => {\n if (await startProviderSignIn(provider)) {\n provider.connectedAt = Date.now();\n await this.plugin.saveSettings();\n this.display();\n }\n });\n }\n}\n\nclass ConfirmActionsModal extends Modal {\n private actions: VaultAction[];\n private resolve: (approved: boolean) => void;\n\n constructor(app: App, actions: VaultAction[], resolve: (approved: boolean) => void) {\n super(app);\n this.actions = actions;\n this.resolve = resolve;\n }\n\n onOpen() {\n const { contentEl } = this;\n contentEl.empty();\n contentEl.createEl(\"h2\", { text: \"Apply AI vault actions?\" });\n contentEl.createEl(\"p\", { text: \"Review these proposed file changes before they are applied.\" });\n\n for (const action of this.actions) {\n const actionEl = contentEl.createDiv(\"ai-sidebar-action-preview\");\n actionEl.createEl(\"strong\", { text: `${action.type}: ${action.path}` });\n if (action.type === \"rename\") {\n actionEl.createDiv({ text: `New path: ${action.newPath}` });\n }\n if (\"content\" in action) {\n actionEl.createEl(\"pre\", { text: action.content.slice(0, 1200) });\n }\n }\n\n new Setting(contentEl)\n .addButton((button) => {\n button.setButtonText(\"Cancel\").onClick(() => {\n this.resolve(false);\n this.close();\n });\n })\n .addButton((button) => {\n button.setCta().setButtonText(\"Apply\").onClick(() => {\n this.resolve(true);\n this.close();\n });\n });\n }\n\n onClose() {\n this.contentEl.empty();\n }\n}\n\nasync function collectVaultContext(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n selectedSkillNames: string[] = [],\n): Promise<VaultContext> {\n const activeView = app.workspace.getActiveViewOfType(MarkdownView);\n const activeFile = activeView?.file ?? app.workspace.getActiveFile();\n const selection = activeView?.editor.getSelection() || undefined;\n const budget = new ContextBudget(settings.maxContextChars);\n\n const context: VaultContext = {\n selection,\n linkedFiles: [],\n backlinkFiles: [],\n relevantFiles: [],\n baseFiles: [],\n selectedSkills: [],\n };\n\n const activeContext = activeFile ? await readContextFile(app, activeFile, \"active note\", budget) : undefined;\n if (activeContext) context.activeFile = activeContext;\n\n const linked = activeFile ? getLinkedFiles(app, activeFile) : [];\n for (const file of linked) {\n const item = await readContextFile(app, file, \"linked note\", budget);\n if (item) context.linkedFiles.push(item);\n }\n\n const backlinks = activeFile ? getBacklinkFiles(app, activeFile) : [];\n for (const file of backlinks) {\n const item = await readContextFile(app, file, \"backlink\", budget);\n if (item) context.backlinkFiles.push(item);\n }\n\n const baseFiles = app.vault\n .getFiles()\n .filter((file) => file.extension === \"base\" && shouldIncludeFile(file, settings))\n .slice(0, 5);\n for (const file of baseFiles) {\n const item = await readContextFile(app, file, \"base file\", budget);\n if (item) context.baseFiles.push(item);\n }\n\n const relatedFiles = await findRelevantFiles(app, settings, prompt, activeFile, budget.remaining());\n for (const file of relatedFiles) {\n const item = await readContextFile(app, file, \"relevant match\", budget);\n if (item) context.relevantFiles.push(item);\n }\n\n context.selectedSkills = await readSelectedSkills(app, selectedSkillNames, budget);\n\n return context;\n}\n\nasync function readContextFile(\n app: App,\n file: TFile,\n reason: string,\n budget: ContextBudget,\n): Promise<VaultContextFile | undefined> {\n if (!budget.hasRoom()) return undefined;\n const content = await app.vault.cachedRead(file);\n const clipped = budget.take(content);\n return { path: file.path, content: clipped, reason };\n}\n\nfunction getLinkedFiles(app: App, activeFile: TFile): TFile[] {\n const cache = app.metadataCache.getFileCache(activeFile);\n const links = cache?.links ?? [];\n const files: TFile[] = [];\n\n for (const link of links) {\n const linkedFile = app.metadataCache.getFirstLinkpathDest(link.link, activeFile.path);\n if (linkedFile) files.push(linkedFile);\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nfunction getBacklinkFiles(app: App, activeFile: TFile): TFile[] {\n const resolvedLinks = app.metadataCache.resolvedLinks;\n const files: TFile[] = [];\n\n for (const [sourcePath, targets] of Object.entries(resolvedLinks)) {\n if (targets[activeFile.path]) {\n const file = app.vault.getAbstractFileByPath(sourcePath);\n if (file instanceof TFile) files.push(file);\n }\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nasync function findRelevantFiles(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n activeFile: TFile | null,\n maxChars: number,\n): Promise<TFile[]> {\n if (!prompt.trim() || maxChars <= 0) return [];\n const terms = prompt\n .toLowerCase()\n .split(/[^a-z0-9/_-]+/i)\n .filter((term) => term.length > 2)\n .slice(0, 16);\n if (terms.length === 0) return [];\n\n const scored: Array<{ file: TFile; score: number }> = [];\n const files = app.vault\n .getFiles()\n .filter((file) => file !== activeFile && shouldIncludeFile(file, settings) && isReadableContextFile(file));\n\n for (const file of files) {\n const content = (await app.vault.cachedRead(file)).toLowerCase();\n const path = file.path.toLowerCase();\n let score = 0;\n for (const term of terms) {\n if (path.includes(term)) score += 4;\n const matches = content.match(new RegExp(escapeRegExp(term), \"g\"));\n score += matches ? Math.min(matches.length, 8) : 0;\n }\n if (score > 0) scored.push({ file, score });\n }\n\n return scored\n .sort((a, b) => b.score - a.score)\n .slice(0, 10)\n .map((item) => item.file);\n}\n\nfunction shouldIncludeFile(file: TFile, settings: AISidebarSettings): boolean {\n const includes = parseFolderList(settings.includeFolders);\n const excludes = parseFolderList(settings.excludeFolders);\n if (includes.length > 0 && !includes.some((folder) => file.path.startsWith(folder))) return false;\n if (excludes.some((folder) => file.path.startsWith(folder))) return false;\n return isReadableContextFile(file);\n}\n\nfunction isReadableContextFile(file: TFile): boolean {\n return [\"md\", \"canvas\", \"base\", \"json\", \"txt\"].includes(file.extension);\n}\n\nfunction nativeSlashCommands(\n provider: AgentProvider,\n query: string,\n currentAccessMode: AccessMode,\n): Array<{ label: string; description: string; insert: string }> {\n const commands: Array<{ label: string; description: string; insert: string }> = [];\n const modelChoices = parseProviderModels(provider);\n\n for (const model of modelChoices) {\n commands.push({\n label: `/model:${model}`,\n description: `Use ${model} for this request`,\n insert: `/model:${model}`,\n });\n }\n\n if (provider.authType === \"api-key\" || provider.id.includes(\"codex\") || provider.id.includes(\"openai\")) {\n for (const effort of [\"low\", \"medium\", \"high\"] as ReasoningEffort[]) {\n commands.push({\n label: `/reasoning:${effort}`,\n description: `Set reasoning effort to ${effort}`,\n insert: `/reasoning:${effort}`,\n });\n }\n }\n\n for (const mode of [\"read-only\", \"confirm\", \"full-access\"] as AccessMode[]) {\n commands.push({\n label: `/access:${mode}`,\n description: mode === currentAccessMode ? \"Current vault access mode\" : `Use ${mode} for this request`,\n insert: `/access:${mode}`,\n });\n }\n\n commands.push(\n { label: \"/memory:on\", description: \"Include remembered chat history for this request\", insert: \"/memory:on\" },\n { label: \"/memory:off\", description: \"Do not include remembered chat history for this request\", insert: \"/memory:off\" },\n );\n\n const normalizedQuery = query.toLowerCase();\n return commands.filter((command) => command.label.slice(1).toLowerCase().includes(normalizedQuery));\n}\n\nfunction parseProviderModels(provider: AgentProvider): string[] {\n const configured = provider.models\n ?.split(\",\")\n .map((model) => model.trim())\n .filter(Boolean) ?? [];\n const models = configured.length > 0 ? configured : [provider.model].filter((model): model is string => Boolean(model));\n return Array.from(new Set(models)).slice(0, 12);\n}\n\nasync function listAgentSkills(app: App, query = \"\"): Promise<Array<{ name: string; path: string }>> {\n const normalizedQuery = query.toLowerCase();\n const skills = app.vault\n .getFiles()\n .filter((file) => isSkillFile(file))\n .map((file) => ({\n name: skillNameFromPath(file.path),\n path: file.path,\n }))\n .filter((skill) => !normalizedQuery || skill.name.toLowerCase().includes(normalizedQuery))\n .sort((a, b) => a.name.localeCompare(b.name));\n\n const seen = new Set<string>();\n return skills.filter((skill) => {\n const key = skill.name.toLowerCase();\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\nasync function readSelectedSkills(\n app: App,\n selectedSkillNames: string[],\n budget: ContextBudget,\n): Promise<AgentSkill[]> {\n const names = new Set(selectedSkillNames.map((name) => name.toLowerCase()));\n if (names.size === 0) return [];\n\n const skills: AgentSkill[] = [];\n for (const file of app.vault.getFiles().filter((candidate) => isSkillFile(candidate))) {\n const name = skillNameFromPath(file.path);\n if (!names.has(name.toLowerCase()) || !budget.hasRoom()) continue;\n const content = await app.vault.cachedRead(file);\n skills.push({\n name,\n path: file.path,\n content: budget.take(content),\n });\n }\n return skills;\n}\n\nfunction isSkillFile(file: TFile): boolean {\n if (![\"md\", \"txt\", \"json\", \"yaml\", \"yml\"].includes(file.extension)) return false;\n return file.path.startsWith(\".agent/skills/\") || file.path.startsWith(\".agents/skills/\");\n}\n\nfunction skillNameFromPath(path: string): string {\n const parts = path.split(\"/\");\n const fileName = parts.last() ?? path;\n if (fileName.toLowerCase() === \"skill.md\" && parts.length > 1) {\n return parts[parts.length - 2];\n }\n return fileName.replace(/\\.[^.]+$/, \"\");\n}\n\nfunction parseFolderList(value: string): string[] {\n return value\n .split(\",\")\n .map((folder) => folder.trim().replace(/^\\/+/, \"\"))\n .filter(Boolean);\n}\n\nclass ContextBudget {\n private remainingChars: number;\n\n constructor(maxChars: number) {\n this.remainingChars = maxChars;\n }\n\n hasRoom(): boolean {\n return this.remainingChars > 0;\n }\n\n remaining(): number {\n return this.remainingChars;\n }\n\n take(content: string): string {\n const clipped = content.slice(0, this.remainingChars);\n this.remainingChars -= clipped.length;\n return clipped;\n }\n}\n\nfunction buildAgentInstructions(accessMode: AccessMode): string {\n return [\n \"You are running inside an Obsidian AI Sidebar plugin.\",\n \"Use the provided vault context to answer the user.\",\n \"If the prompt contains slash commands such as /writer or /reviewer, apply the matching selectedSkills instructions from the request context.\",\n \"The request may include conversation memory and per-request options such as model or reasoning effort.\",\n \"When you need file changes, include a JSON code block with this shape:\",\n '{\"actions\":[{\"type\":\"edit\",\"path\":\"Note.md\",\"content\":\"new content\"}]}',\n \"Allowed action types: create, edit, append, delete, rename.\",\n `Current access mode: ${accessMode}. The plugin enforces this mode before applying actions.`,\n ].join(\"\\n\");\n}\n\nasync function runProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n if (provider.authType === \"api-key\") {\n return runOpenAICompatibleProvider(provider, request);\n }\n\n if (provider.authType === \"oauth\") {\n if (!provider.accessToken) {\n throw new Error(\"This OAuth provider is not connected yet.\");\n }\n return runOAuthProvider(provider, request);\n }\n\n return runCliProvider(provider, request, app);\n}\n\nasync function runOpenAICompatibleProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiKey) throw new Error(\"Missing API key.\");\n const url = provider.apiBaseUrl || \"https://api.openai.com/v1/responses\";\n const model = request.options.model || provider.model || \"gpt-4.1-mini\";\n const userPayload = JSON.stringify({\n prompt: request.prompt,\n accessMode: request.accessMode,\n context: request.context,\n options: request.options,\n });\n const messages = [\n { role: \"system\", content: request.instructions },\n ...request.conversation.map((message) => ({\n role: message.role === \"assistant\" ? \"assistant\" : \"user\",\n content: message.content,\n })),\n { role: \"user\", content: userPayload },\n ];\n const usesChatCompletions = url.includes(\"/chat/completions\");\n const body: Record<string, unknown> = usesChatCompletions\n ? { model, messages }\n : { model, input: messages };\n if (request.options.reasoningEffort && model.startsWith(\"o\")) {\n body.reasoning = { effort: request.options.reasoningEffort };\n }\n const response = await requestUrl({\n url,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n return extractOpenAIText(response.json);\n}\n\nasync function runOAuthProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiBaseUrl) {\n throw new Error(\"OAuth provider needs an API endpoint after login.\");\n }\n const response = await requestUrl({\n url: provider.apiBaseUrl,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.accessToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(request),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n if (typeof response.json?.text === \"string\") return response.json.text;\n if (typeof response.json?.output_text === \"string\") return response.json.output_text;\n return response.text;\n}\n\nfunction runCliProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {\n return new Promise((resolve, reject) => {\n const prompt = cliPromptFromRequest(request);\n const command = cliCommandForRequest(provider, request, app);\n const shellCommand = `${command} ${shellQuote(prompt)}`;\n const child = spawn(\"/bin/zsh\", [\"-lc\", shellCommand], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n let stdout = \"\";\n let stderr = \"\";\n\n child.stdout.on(\"data\", (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) {\n resolve(stdout.trim());\n } else {\n reject(new Error(stderr.trim() || `Command exited with code ${code}. Check that ${command} is installed and available in your shell.`));\n }\n });\n\n });\n}\n\nfunction cliCommandForRequest(provider: AgentProvider, request: AgentRequest, app: App): string {\n const vaultPath = getVaultPath(app);\n if (provider.id === \"codex\" || provider.command.startsWith(\"codex exec\")) {\n const sandbox = providerSandboxForAccessMode(request.accessMode);\n const cdArg = vaultPath ? ` -C ${shellQuote(vaultPath)}` : \"\";\n return `${provider.command}${cdArg} --skip-git-repo-check -s ${sandbox}`;\n }\n return provider.command;\n}\n\nfunction providerSandboxForAccessMode(accessMode: AccessMode): string {\n if (accessMode === \"full-access\") return \"workspace-write\";\n return \"read-only\";\n}\n\nfunction getVaultPath(app: App): string | undefined {\n const adapter = app.vault.adapter;\n if (\"basePath\" in adapter && typeof adapter.basePath === \"string\") {\n return adapter.basePath;\n }\n return undefined;\n}\n\nfunction cliPromptFromRequest(request: AgentRequest): string {\n return [\n request.instructions,\n \"\",\n \"User request:\",\n request.prompt,\n \"\",\n \"Vault context and options:\",\n JSON.stringify({\n accessMode: request.accessMode,\n options: request.options,\n conversation: request.options.memoryEnabled ? request.conversation : [],\n context: request.context,\n }, null, 2),\n ].join(\"\\n\");\n}\n\nfunction openInteractiveTerminal(command: string, title: string): Promise<void> {\n const escapedCommand = escapeForSingleQuotedShell(command);\n const script = [\n `tell application \"Terminal\"`,\n \"activate\",\n `do script \"printf '\\\\\\\\033]0;${escapeForAppleScript(title)}\\\\\\\\007'; clear; echo 'AI Sidebar sign-in'; echo ''; echo 'Running: ${escapeForAppleScript(command)}'; echo ''; /bin/zsh -lc '${escapedCommand}; echo; echo Sign-in command finished. You can close this window when done.'\"`\n ,\n \"end tell\",\n ].join(\"\\n\");\n\n return new Promise((resolve, reject) => {\n const child = spawn(\"osascript\", [\"-e\", script], {\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n });\n let stderr = \"\";\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) resolve();\n else reject(new Error(stderr.trim() || `Could not open Terminal. Exit code ${code}`));\n });\n });\n}\n\nfunction extractOpenAIText(json: unknown): string {\n if (!json || typeof json !== \"object\") return \"\";\n const response = json as {\n output_text?: string;\n output?: Array<{ content?: Array<{ text?: string; type?: string }> }>;\n choices?: Array<{ message?: { content?: string } }>;\n };\n if (typeof response.output_text === \"string\") return response.output_text;\n if (typeof response.choices?.[0]?.message?.content === \"string\") {\n return response.choices[0].message.content;\n }\n const parts: string[] = [];\n for (const item of response.output ?? []) {\n for (const content of item.content ?? []) {\n if (typeof content.text === \"string\") parts.push(content.text);\n }\n }\n return parts.join(\"\\n\");\n}\n\nasync function startOAuthLogin(provider: AgentProvider): Promise<boolean> {\n if (!provider.authorizationUrl || !provider.clientId) {\n new Notice(\"Add an authorization URL and client ID first.\");\n return false;\n }\n\n const url = new URL(provider.authorizationUrl);\n url.searchParams.set(\"response_type\", \"token\");\n url.searchParams.set(\"client_id\", provider.clientId);\n url.searchParams.set(\"redirect_uri\", \"urn:ietf:wg:oauth:2.0:oob\");\n if (provider.scope) url.searchParams.set(\"scope\", provider.scope);\n url.searchParams.set(\"state\", crypto.randomUUID());\n\n window.open(url.toString());\n new Notice(\"OAuth sign-in opened in your browser. Paste the returned token into this provider when available.\");\n return true;\n}\n\nasync function startProviderSignIn(provider: AgentProvider): Promise<boolean> {\n if (provider.authType === \"cli\") {\n if (!provider.signInCommand) {\n new Notice(\"This local provider does not have a sign-in command configured.\");\n return false;\n }\n try {\n await openInteractiveTerminal(provider.signInCommand, `${provider.name} Sign In`);\n new Notice(`Opened Terminal for ${provider.name} sign-in.`);\n return true;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n new Notice(`Could not open sign-in: ${message}`);\n return false;\n }\n }\n\n if (provider.authType === \"api-key\") {\n if (provider.setupUrl) window.open(provider.setupUrl);\n new Notice(provider.apiKey ? `${provider.name} is connected.` : `Add your ${provider.name} API key below to connect.`);\n return Boolean(provider.apiKey);\n }\n\n return startOAuthLogin(provider);\n}\n\nfunction escapeForSingleQuotedShell(value: string): string {\n return value.replace(/'/g, \"'\\\\''\");\n}\n\nfunction shellQuote(value: string): string {\n return `'${escapeForSingleQuotedShell(value)}'`;\n}\n\nfunction escapeForAppleScript(value: string): string {\n return value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\nfunction providerConnectionActionText(provider: AgentProvider): string {\n if (provider.authType === \"cli\") return isProviderConnected(provider) ? \"Reconnect\" : \"Sign in\";\n if (provider.authType === \"api-key\") return provider.apiKey ? \"Connected\" : \"Get key\";\n return provider.accessToken ? \"Reconnect\" : \"Sign in\";\n}\n\nfunction providerConnectionLabel(provider: AgentProvider): string {\n if (isProviderConnected(provider)) return \"Connected\";\n if (provider.authType === \"api-key\") return \"Needs API key\";\n if (provider.authType === \"oauth\") return \"Needs sign-in\";\n return provider.signInCommand ? \"Ready to sign in\" : \"Needs command\";\n}\n\nfunction isProviderConnected(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken);\n return Boolean(provider.connectedAt);\n}\n\nfunction isProviderReady(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey && provider.apiBaseUrl);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken && provider.apiBaseUrl);\n return Boolean(provider.command.trim());\n}\n\nfunction normalizeProviders(providers: AgentProvider[]): AgentProvider[] {\n return providers.map((provider) => {\n const migrated = provider as AgentProvider;\n const command = migrated.id === \"codex\" && migrated.command === \"codex\"\n ? \"codex exec\"\n : migrated.command ?? \"\";\n return {\n ...migrated,\n authType: migrated.authType ?? (migrated.command ? \"cli\" : \"api-key\"),\n command,\n connectedAt: migrated.connectedAt,\n };\n });\n}\n\nfunction cloneProvider(provider: AgentProvider): AgentProvider {\n return { ...provider };\n}\n\nfunction extractVaultActions(response: string): VaultAction[] {\n const actions: VaultAction[] = [];\n const codeBlockPattern = /```(?:json)?\\s*([\\s\\S]*?)```/gi;\n const candidates: string[] = [];\n let match: RegExpExecArray | null;\n\n while ((match = codeBlockPattern.exec(response)) !== null) {\n candidates.push(match[1]);\n }\n candidates.push(response);\n\n for (const candidate of candidates) {\n try {\n const parsed = JSON.parse(candidate.trim()) as { actions?: VaultAction[] };\n if (Array.isArray(parsed.actions)) {\n actions.push(...parsed.actions.filter(isVaultAction));\n }\n } catch {\n continue;\n }\n }\n\n return actions;\n}\n\nfunction isVaultAction(value: unknown): value is VaultAction {\n if (!value || typeof value !== \"object\") return false;\n const action = value as Partial<VaultAction>;\n if (typeof action.type !== \"string\" || typeof action.path !== \"string\") return false;\n if ([\"create\", \"edit\", \"append\"].includes(action.type)) {\n return typeof (action as { content?: unknown }).content === \"string\";\n }\n if (action.type === \"delete\") return true;\n if (action.type === \"rename\") {\n return typeof (action as { newPath?: unknown }).newPath === \"string\";\n }\n return false;\n}\n\nfunction confirmVaultActions(app: App, actions: VaultAction[]): Promise<boolean> {\n return new Promise((resolve) => {\n new ConfirmActionsModal(app, actions, resolve).open();\n });\n}\n\nasync function applyVaultAction(app: App, action: VaultAction): Promise<void> {\n const path = normalizeVaultPath(action.path);\n const file = app.vault.getAbstractFileByPath(path);\n\n if (action.type === \"create\") {\n if (file) throw new Error(\"File already exists.\");\n await ensureParentFolder(app, path);\n await app.vault.create(path, action.content);\n return;\n }\n\n if (!(file instanceof TFile)) {\n throw new Error(\"File does not exist.\");\n }\n\n if (action.type === \"edit\") {\n await app.vault.modify(file, action.content);\n } else if (action.type === \"append\") {\n const current = await app.vault.read(file);\n await app.vault.modify(file, `${current}${current.endsWith(\"\\n\") ? \"\" : \"\\n\"}${action.content}`);\n } else if (action.type === \"delete\") {\n await app.vault.delete(file);\n } else if (action.type === \"rename\") {\n await ensureParentFolder(app, action.newPath);\n await app.fileManager.renameFile(file, normalizeVaultPath(action.newPath));\n }\n}\n\nasync function ensureParentFolder(app: App, path: string): Promise<void> {\n const parts = normalizeVaultPath(path).split(\"/\");\n parts.pop();\n let current = \"\";\n for (const part of parts) {\n current = current ? `${current}/${part}` : part;\n if (!app.vault.getAbstractFileByPath(current)) {\n await app.vault.createFolder(current);\n }\n }\n}\n\nfunction normalizeVaultPath(path: string): string {\n return path.replace(/^\\/+/, \"\").replace(/\\\\/g, \"/\");\n}\n\nfunction uniqueFiles(files: TFile[]): TFile[] {\n const seen = new Set<string>();\n return files.filter((file) => {\n if (seen.has(file.path)) return false;\n seen.add(file.path);\n return true;\n });\n}\n\nfunction sanitizeProviderId(value: string): string {\n return value.toLowerCase().replace(/[^a-z0-9_-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || \"provider\";\n}\n\nfunction isReasoningEffort(value: string): value is ReasoningEffort {\n return value === \"low\" || value === \"medium\" || value === \"high\";\n}\n\nfunction isAccessMode(value: string): value is AccessMode {\n return value === \"read-only\" || value === \"confirm\" || value === \"full-access\";\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"}, "03516806a73e359c6cbdfe7a72329ea63ecc9c8f": {"t": 1785759154.7434912, "n": 58802, "h": "9c20f758587f5293", "c": "import {\n App,\n ButtonComponent,\n DropdownComponent,\n ItemView,\n MarkdownView,\n Modal,\n Notice,\n Plugin,\n PluginSettingTab,\n requestUrl,\n Setting,\n TFile,\n WorkspaceLeaf,\n} from \"obsidian\";\nimport { spawn } from \"child_process\";\n\nconst VIEW_TYPE_AI_SIDEBAR = \"ai-sidebar-view\";\n\ntype AccessMode = \"read-only\" | \"confirm\" | \"full-access\";\ntype ProviderAuthType = \"api-key\" | \"oauth\" | \"cli\";\ntype ReasoningEffort = \"low\" | \"medium\" | \"high\";\n\ninterface AgentProvider {\n id: string;\n name: string;\n authType: ProviderAuthType;\n command: string;\n signInCommand?: string;\n setupUrl?: string;\n apiBaseUrl?: string;\n apiKey?: string;\n model?: string;\n models?: string;\n reasoningEffort?: ReasoningEffort;\n authorizationUrl?: string;\n tokenUrl?: string;\n clientId?: string;\n scope?: string;\n accessToken?: string;\n refreshToken?: string;\n tokenExpiresAt?: number;\n}\n\ninterface AISidebarSettings {\n providers: AgentProvider[];\n defaultProviderId: string;\n defaultAccessMode: AccessMode;\n includeFolders: string;\n excludeFolders: string;\n maxContextChars: number;\n enableConversationMemory: boolean;\n maxMemoryMessages: number;\n rememberedMessages: ChatMessage[];\n}\n\ninterface VaultContextFile {\n path: string;\n content: string;\n reason: string;\n}\n\ninterface AgentSkill {\n name: string;\n path: string;\n content: string;\n}\n\ninterface VaultContext {\n activeFile?: VaultContextFile;\n selection?: string;\n linkedFiles: VaultContextFile[];\n backlinkFiles: VaultContextFile[];\n relevantFiles: VaultContextFile[];\n baseFiles: VaultContextFile[];\n selectedSkills: AgentSkill[];\n}\n\ninterface ChatMessage {\n role: \"user\" | \"assistant\" | \"system\";\n content: string;\n}\n\ninterface AgentRequest {\n prompt: string;\n accessMode: AccessMode;\n context: VaultContext;\n instructions: string;\n conversation: ChatMessage[];\n options: AgentRequestOptions;\n}\n\ninterface AgentRequestOptions {\n model?: string;\n reasoningEffort?: ReasoningEffort;\n memoryEnabled: boolean;\n}\n\ninterface ParsedPrompt {\n cleanPrompt: string;\n selectedSkillNames: string[];\n options: AgentRequestOptions;\n accessMode?: AccessMode;\n}\n\ntype VaultAction =\n | { type: \"create\"; path: string; content: string }\n | { type: \"edit\"; path: string; content: string }\n | { type: \"append\"; path: string; content: string }\n | { type: \"delete\"; path: string }\n | { type: \"rename\"; path: string; newPath: string };\n\ninterface ProviderPreset {\n id: string;\n name: string;\n description: string;\n provider: AgentProvider;\n}\n\nconst PROVIDER_PRESETS: ProviderPreset[] = [\n {\n id: \"codex\",\n name: \"Codex\",\n description: \"Sign in with Codex locally and use the Codex CLI agent.\",\n provider: {\n id: \"codex\",\n name: \"Codex\",\n authType: \"cli\",\n command: \"codex\",\n signInCommand: \"codex login\",\n models: \"gpt-5.4, gpt-5.4-mini, gpt-5.3-codex\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"claude\",\n name: \"Claude Code\",\n description: \"Sign in with your Claude subscription or Anthropic Console account.\",\n provider: {\n id: \"claude\",\n name: \"Claude Code\",\n authType: \"cli\",\n command: \"claude\",\n signInCommand: \"claude auth login\",\n setupUrl: \"https://code.claude.com/docs/en/cli-usage\",\n models: \"opus, sonnet, haiku\",\n },\n },\n {\n id: \"opencode\",\n name: \"opencode\",\n description: \"Connect opencode to any provider it supports, including subscription/API accounts.\",\n provider: {\n id: \"opencode\",\n name: \"opencode\",\n authType: \"cli\",\n command: \"opencode\",\n signInCommand: \"opencode auth login\",\n setupUrl: \"https://opencode.ai/docs/cli/\",\n models: \"anthropic/claude-sonnet-4-5, openai/gpt-5.4, google/gemini-2.5-pro\",\n },\n },\n {\n id: \"openai\",\n name: \"OpenAI API\",\n description: \"Use an OpenAI API key with the Responses API.\",\n provider: {\n id: \"openai\",\n name: \"OpenAI\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.openai.com/api-keys\",\n apiBaseUrl: \"https://api.openai.com/v1/responses\",\n model: \"gpt-4.1-mini\",\n models: \"gpt-4.1-mini, gpt-4.1, o4-mini\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n description: \"Use an Anthropic API key directly through an OpenAI-compatible proxy endpoint.\",\n provider: {\n id: \"anthropic-api\",\n name: \"Anthropic API\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.anthropic.com/settings/keys\",\n apiBaseUrl: \"\",\n model: \"claude-sonnet-4-5\",\n models: \"claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5\",\n },\n },\n {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n description: \"Use Google Gemini CLI with its native sign-in and subscription/API setup.\",\n provider: {\n id: \"gemini-cli\",\n name: \"Gemini CLI\",\n authType: \"cli\",\n command: \"gemini\",\n signInCommand: \"gemini\",\n setupUrl: \"https://google-gemini.github.io/gemini-cli/docs/\",\n models: \"gemini-2.5-pro, gemini-2.5-flash\",\n },\n },\n {\n id: \"openrouter\",\n name: \"OpenRouter\",\n description: \"Use one API key for many model providers through an OpenAI-compatible endpoint.\",\n provider: {\n id: \"openrouter\",\n name: \"OpenRouter\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://openrouter.ai/settings/keys\",\n apiBaseUrl: \"https://openrouter.ai/api/v1/chat/completions\",\n model: \"openai/gpt-4.1-mini\",\n models: \"openai/gpt-4.1-mini, anthropic/claude-sonnet-4.5, google/gemini-2.5-pro\",\n reasoningEffort: \"medium\",\n },\n },\n {\n id: \"deepseek\",\n name: \"DeepSeek\",\n description: \"Use DeepSeek with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://platform.deepseek.com/api_keys\",\n apiBaseUrl: \"https://api.deepseek.com/v1/chat/completions\",\n model: \"deepseek-chat\",\n models: \"deepseek-chat, deepseek-reasoner\",\n },\n },\n {\n id: \"mistral\",\n name: \"Mistral\",\n description: \"Use Mistral models with an API key and OpenAI-compatible endpoint.\",\n provider: {\n id: \"mistral\",\n name: \"Mistral\",\n authType: \"api-key\",\n command: \"\",\n setupUrl: \"https://console.mistral.ai/api-keys\",\n apiBaseUrl: \"https://api.mistral.ai/v1/chat/completions\",\n model: \"mistral-large-latest\",\n models: \"mistral-large-latest, codestral-latest, ministral-8b-latest\",\n },\n },\n {\n id: \"aider\",\n name: \"Aider\",\n description: \"Use Aider with keys from OpenAI, Anthropic, Gemini, OpenRouter, DeepSeek, and more.\",\n provider: {\n id: \"aider\",\n name: \"Aider\",\n authType: \"cli\",\n command: \"aider\",\n signInCommand: \"aider\",\n setupUrl: \"https://aider.chat/docs/config/api-keys.html\",\n models: \"sonnet, opus, gpt-4.1, o4-mini, gemini/gemini-2.5-pro\",\n },\n },\n];\n\nconst DEFAULT_SETTINGS: AISidebarSettings = {\n providers: [\n cloneProvider(PROVIDER_PRESETS[0].provider),\n cloneProvider(PROVIDER_PRESETS[3].provider),\n ],\n defaultProviderId: \"codex\",\n defaultAccessMode: \"confirm\",\n includeFolders: \"\",\n excludeFolders: \".obsidian, node_modules, .git\",\n maxContextChars: 45000,\n enableConversationMemory: true,\n maxMemoryMessages: 30,\n rememberedMessages: [],\n};\n\nexport default class AISidebarPlugin extends Plugin {\n settings: AISidebarSettings;\n\n async onload() {\n await this.loadSettings();\n\n this.registerView(\n VIEW_TYPE_AI_SIDEBAR,\n (leaf) => new AISidebarView(leaf, this),\n );\n\n this.addRibbonIcon(\"sparkles\", \"Toggle AI Sidebar\", () => {\n void this.toggleSidebar();\n });\n\n this.addCommand({\n id: \"toggle-ai-sidebar\",\n name: \"Toggle AI Sidebar\",\n hotkeys: [{ modifiers: [\"Mod\", \"Shift\"], key: \"A\" }],\n callback: () => {\n void this.toggleSidebar();\n },\n });\n\n this.addSettingTab(new AISidebarSettingTab(this.app, this));\n }\n\n onunload() {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n }\n\n async toggleSidebar() {\n const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AI_SIDEBAR).first();\n if (existingLeaf) {\n this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);\n return;\n }\n\n const leaf = this.app.workspace.getRightLeaf(false);\n if (!leaf) {\n new Notice(\"Could not open the AI sidebar.\");\n return;\n }\n\n await leaf.setViewState({ type: VIEW_TYPE_AI_SIDEBAR, active: true });\n this.app.workspace.revealLeaf(leaf);\n }\n\n async loadSettings() {\n const loaded = await this.loadData();\n this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);\n this.settings.providers = normalizeProviders(this.settings.providers);\n this.settings.rememberedMessages = Array.isArray(this.settings.rememberedMessages)\n ? this.settings.rememberedMessages\n : [];\n }\n\n async saveSettings() {\n await this.saveData(this.settings);\n }\n\n getProvider(id: string): AgentProvider | undefined {\n return this.settings.providers.find((provider) => provider.id === id);\n }\n\n async rememberMessages(messages: ChatMessage[]) {\n if (!this.settings.enableConversationMemory) return;\n this.settings.rememberedMessages = messages.slice(-this.settings.maxMemoryMessages);\n await this.saveSettings();\n }\n\n async clearMemory() {\n this.settings.rememberedMessages = [];\n await this.saveSettings();\n }\n\n async upsertProviderFromPreset(preset: ProviderPreset) {\n const provider = cloneProvider(preset.provider);\n const existingIndex = this.settings.providers.findIndex((item) => item.id === provider.id);\n if (existingIndex >= 0) {\n this.settings.providers[existingIndex] = {\n ...provider,\n apiKey: this.settings.providers[existingIndex].apiKey,\n accessToken: this.settings.providers[existingIndex].accessToken,\n refreshToken: this.settings.providers[existingIndex].refreshToken,\n };\n } else {\n this.settings.providers.push(provider);\n }\n this.settings.defaultProviderId = provider.id;\n await this.saveSettings();\n }\n}\n\nclass AISidebarView extends ItemView {\n private plugin: AISidebarPlugin;\n private messages: ChatMessage[] = [];\n private providerId: string;\n private accessMode: AccessMode;\n private threadEl: HTMLElement;\n private textareaEl: HTMLTextAreaElement;\n private contextEl: HTMLElement;\n private slashEl: HTMLElement;\n private activeContextPath = \"\";\n private refreshTimer: number | undefined;\n private sendButton: ButtonComponent;\n private isRunning = false;\n\n constructor(leaf: WorkspaceLeaf, plugin: AISidebarPlugin) {\n super(leaf);\n this.plugin = plugin;\n this.messages = plugin.settings.enableConversationMemory\n ? [...plugin.settings.rememberedMessages]\n : [];\n this.providerId = plugin.settings.defaultProviderId;\n this.accessMode = plugin.settings.defaultAccessMode;\n }\n\n getViewType(): string {\n return VIEW_TYPE_AI_SIDEBAR;\n }\n\n getDisplayText(): string {\n return \"AI Sidebar\";\n }\n\n getIcon(): string {\n return \"sparkles\";\n }\n\n async onOpen() {\n this.render();\n this.registerEvent(\n this.app.workspace.on(\"active-leaf-change\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.workspace.on(\"file-open\", () => {\n this.scheduleContextRefresh();\n }),\n );\n this.registerEvent(\n this.app.vault.on(\"modify\", (file) => {\n if (file instanceof TFile && file.path === this.activeContextPath) {\n this.scheduleContextRefresh();\n }\n }),\n );\n await this.refreshContextPreview();\n }\n\n async onClose() {\n this.contentEl.empty();\n }\n\n private render() {\n this.contentEl.empty();\n this.contentEl.addClass(\"ai-sidebar\");\n\n const header = this.contentEl.createDiv(\"ai-sidebar__header\");\n header.createDiv({ cls: \"ai-sidebar__title\", text: \"AI Sidebar\" });\n header.createDiv({ cls: \"ai-sidebar__subtitle\", text: \"Vault-aware agents\" });\n\n const controls = this.contentEl.createDiv(\"ai-sidebar__controls\");\n new DropdownComponent(controls)\n .addOptions(this.providerOptions())\n .setValue(this.providerId)\n .onChange((value) => {\n this.providerId = value;\n });\n\n new DropdownComponent(controls)\n .addOptions({\n \"read-only\": \"Read only\",\n confirm: \"Confirm actions\",\n \"full-access\": \"Full access\",\n })\n .setValue(this.accessMode)\n .onChange((value: AccessMode) => {\n this.accessMode = value;\n void this.refreshContextPreview();\n });\n\n this.contextEl = this.contentEl.createDiv(\"ai-sidebar__context\");\n\n this.threadEl = this.contentEl.createDiv(\"ai-sidebar__thread\");\n this.renderMessages();\n\n const composer = this.contentEl.createDiv(\"ai-sidebar__composer\");\n this.textareaEl = composer.createEl(\"textarea\", {\n cls: \"ai-sidebar__input\",\n attr: {\n placeholder: \"Ask about your vault, draft a note, or request an edit...\",\n rows: \"4\",\n },\n });\n\n this.textareaEl.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Escape\" && !this.slashEl.hasClass(\"is-hidden\")) {\n event.preventDefault();\n this.hideSlashCommands();\n return;\n }\n if ((event.metaKey || event.ctrlKey) && event.key === \"Enter\") {\n event.preventDefault();\n void this.sendPrompt();\n }\n });\n this.textareaEl.addEventListener(\"input\", () => {\n void this.updateSlashCommands();\n });\n\n this.slashEl = composer.createDiv(\"ai-sidebar__slash is-hidden\");\n\n const actions = composer.createDiv(\"ai-sidebar__composer-actions\");\n new ButtonComponent(actions)\n .setButtonText(\"Refresh context\")\n .onClick(() => {\n void this.refreshContextPreview();\n });\n new ButtonComponent(actions)\n .setButtonText(\"Clear\")\n .onClick(() => {\n void this.clearConversation();\n });\n\n this.sendButton = new ButtonComponent(actions)\n .setCta()\n .setButtonText(\"Send\")\n .onClick(() => {\n void this.sendPrompt();\n });\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderMessages() {\n this.threadEl.empty();\n\n if (this.messages.length === 0) {\n const empty = this.threadEl.createDiv(\"ai-sidebar__empty\");\n empty.createDiv({ text: \"Ask a local agent to work with the notes in this vault.\" });\n empty.createDiv({ text: \"Use confirm mode when you want to review file edits first.\" });\n return;\n }\n\n for (const message of this.messages) {\n const messageEl = this.threadEl.createDiv(`ai-sidebar__message ai-sidebar__message--${message.role}`);\n messageEl.createDiv({ cls: \"ai-sidebar__message-role\", text: message.role });\n messageEl.createEl(\"pre\", { text: message.content });\n }\n\n this.threadEl.scrollTo({ top: this.threadEl.scrollHeight });\n }\n\n private async refreshContextPreview(prompt = \"\") {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n prompt,\n this.extractSelectedSkillNames(prompt),\n );\n this.activeContextPath = context.activeFile?.path ?? \"\";\n this.contextEl.empty();\n this.contextEl.createDiv({\n cls: \"ai-sidebar__context-title\",\n text: `Context preview · ${this.accessModeLabel()}`,\n });\n\n const items = [\n context.activeFile ? `Active: ${context.activeFile.path}` : \"No active note\",\n context.selection ? \"Selection included\" : \"No selection\",\n `${context.linkedFiles.length} linked`,\n `${context.backlinkFiles.length} backlinks`,\n `${context.relevantFiles.length} relevant`,\n `${context.baseFiles.length} bases`,\n `${context.selectedSkills.length} skills`,\n ];\n\n const list = this.contextEl.createEl(\"ul\");\n for (const item of items) {\n list.createEl(\"li\", { text: item });\n }\n }\n\n private scheduleContextRefresh() {\n window.clearTimeout(this.refreshTimer);\n this.refreshTimer = window.setTimeout(() => {\n void this.refreshContextPreview(this.textareaEl?.value ?? \"\");\n }, 150);\n }\n\n private accessModeLabel(): string {\n if (this.accessMode === \"read-only\") return \"read only\";\n if (this.accessMode === \"full-access\") return \"full access\";\n return \"confirm actions\";\n }\n\n private async sendPrompt() {\n const rawPrompt = this.textareaEl.value.trim();\n if (!rawPrompt || this.isRunning) return;\n\n const provider = this.plugin.getProvider(this.providerId);\n if (!provider) {\n new Notice(\"Choose a provider in AI Sidebar settings.\");\n return;\n }\n\n if (!isProviderReady(provider)) {\n new Notice(\"Connect or configure this provider in AI Sidebar settings first.\");\n return;\n }\n\n this.isRunning = true;\n this.sendButton.setButtonText(\"Running...\");\n this.textareaEl.value = \"\";\n const parsed = this.parsePromptControls(rawPrompt, provider);\n const effectiveAccessMode = parsed.accessMode ?? this.accessMode;\n this.messages.push({ role: \"user\", content: rawPrompt });\n this.renderMessages();\n\n try {\n const context = await collectVaultContext(\n this.plugin.app,\n this.plugin.settings,\n parsed.cleanPrompt,\n parsed.selectedSkillNames,\n );\n await this.refreshContextPreview(rawPrompt);\n const request: AgentRequest = {\n prompt: parsed.cleanPrompt,\n accessMode: effectiveAccessMode,\n context,\n instructions: buildAgentInstructions(effectiveAccessMode),\n conversation: this.plugin.settings.enableConversationMemory ? this.messages.slice(0, -1) : [],\n options: parsed.options,\n };\n\n const response = await runProvider(provider, request);\n const actionResult = await this.handleActions(response);\n const assistantContent = actionResult ? `${response}\\n\\n${actionResult}` : response;\n this.messages.push({ role: \"assistant\", content: assistantContent.trim() || \"No response.\" });\n await this.plugin.rememberMessages(this.messages);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n this.messages.push({ role: \"system\", content: `Provider failed: ${message}` });\n await this.plugin.rememberMessages(this.messages);\n new Notice(\"AI provider failed. Check the sidebar for details.\");\n } finally {\n this.isRunning = false;\n this.sendButton.setButtonText(\"Send\");\n this.renderMessages();\n }\n }\n\n private async clearConversation() {\n this.messages = [];\n await this.plugin.clearMemory();\n this.renderMessages();\n new Notice(\"AI Sidebar memory cleared.\");\n }\n\n private async handleActions(response: string): Promise<string> {\n const actions = extractVaultActions(response);\n if (actions.length === 0) return \"\";\n\n if (this.accessMode === \"read-only\") {\n return \"Vault actions were proposed, but read-only mode blocked all writes.\";\n }\n\n if (this.accessMode === \"confirm\") {\n const approved = await confirmVaultActions(this.app, actions);\n if (!approved) return \"Vault actions were not applied.\";\n }\n\n const results: string[] = [];\n for (const action of actions) {\n try {\n await applyVaultAction(this.app, action);\n results.push(`Applied ${action.type}: ${action.path}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n results.push(`Failed ${action.type}: ${action.path} · ${message}`);\n }\n }\n return results.join(\"\\n\");\n }\n\n private async updateSlashCommands() {\n const query = this.currentSlashQuery();\n if (query === null) {\n this.hideSlashCommands();\n return;\n }\n\n const provider = this.plugin.getProvider(this.providerId);\n const commands = provider ? nativeSlashCommands(provider, query, this.accessMode) : [];\n const skills = await listAgentSkills(this.plugin.app, query);\n this.slashEl.empty();\n\n if (commands.length === 0 && skills.length === 0) {\n this.hideSlashCommands();\n return;\n }\n\n this.slashEl.removeClass(\"is-hidden\");\n for (const command of commands.slice(0, 10)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: command.label });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: command.description });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(command.insert);\n });\n }\n for (const skill of skills.slice(0, 8)) {\n const item = this.slashEl.createDiv(\"ai-sidebar__slash-item\");\n item.createDiv({ cls: \"ai-sidebar__slash-name\", text: `/${skill.name}` });\n item.createDiv({ cls: \"ai-sidebar__slash-path\", text: skill.path });\n item.addEventListener(\"mousedown\", (event) => {\n event.preventDefault();\n this.insertSlashToken(`/${skill.name}`);\n });\n }\n }\n\n private hideSlashCommands() {\n this.slashEl.empty();\n this.slashEl.addClass(\"is-hidden\");\n }\n\n private currentSlashQuery(): string | null {\n const cursor = this.textareaEl.selectionStart;\n const beforeCursor = this.textareaEl.value.slice(0, cursor);\n const match = beforeCursor.match(/(?:^|\\s)\\/([A-Za-z0-9_.-]*)$/);\n return match ? match[1].toLowerCase() : null;\n }\n\n private insertSlashToken(token: string) {\n const cursor = this.textareaEl.selectionStart;\n const value = this.textareaEl.value;\n const beforeCursor = value.slice(0, cursor);\n const afterCursor = value.slice(cursor);\n const replaced = beforeCursor.replace(/(?:^|\\s)\\/([A-Za-z0-9_.-]*)$/, (match) => {\n const prefix = match.startsWith(\" \") ? \" \" : \"\";\n return `${prefix}${token} `;\n });\n this.textareaEl.value = `${replaced}${afterCursor}`;\n this.textareaEl.focus();\n this.textareaEl.selectionStart = replaced.length;\n this.textareaEl.selectionEnd = replaced.length;\n this.hideSlashCommands();\n this.scheduleContextRefresh();\n }\n\n private extractSelectedSkillNames(prompt: string): string[] {\n const native = new Set([\"model\", \"reasoning\", \"access\", \"memory\"]);\n return Array.from(prompt.matchAll(/(?:^|\\s)\\/([A-Za-z0-9_.-]+)/g), (match) => match[1])\n .filter((name) => !native.has(name.split(\":\")[0].toLowerCase()));\n }\n\n private parsePromptControls(prompt: string, provider: AgentProvider): ParsedPrompt {\n const options: AgentRequestOptions = {\n model: provider.model,\n reasoningEffort: provider.reasoningEffort,\n memoryEnabled: this.plugin.settings.enableConversationMemory,\n };\n let accessMode: AccessMode | undefined;\n\n const cleanPrompt = prompt.replace(/(?:^|\\s)\\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match, key, value) => {\n const normalizedKey = String(key).toLowerCase();\n const normalizedValue = String(value).toLowerCase();\n if (normalizedKey === \"model\") {\n options.model = value;\n } else if (normalizedKey === \"reasoning\" && isReasoningEffort(normalizedValue)) {\n options.reasoningEffort = normalizedValue;\n } else if (normalizedKey === \"access\" && isAccessMode(normalizedValue)) {\n accessMode = normalizedValue;\n } else if (normalizedKey === \"memory\") {\n options.memoryEnabled = normalizedValue === \"on\";\n }\n return match.startsWith(\" \") ? \" \" : \"\";\n }).trim();\n\n return {\n cleanPrompt: cleanPrompt || prompt,\n selectedSkillNames: this.extractSelectedSkillNames(prompt),\n options,\n accessMode,\n };\n }\n}\n\nclass AISidebarSettingTab extends PluginSettingTab {\n plugin: AISidebarPlugin;\n\n constructor(app: App, plugin: AISidebarPlugin) {\n super(app, plugin);\n this.plugin = plugin;\n }\n\n display(): void {\n const { containerEl } = this;\n containerEl.empty();\n containerEl.createEl(\"h2\", { text: \"AI Sidebar\" });\n\n new Setting(containerEl)\n .setName(\"Default provider\")\n .setDesc(\"The AI provider used when the sidebar opens.\")\n .addDropdown((dropdown) => {\n dropdown.addOptions(this.providerOptions());\n dropdown.setValue(this.plugin.settings.defaultProviderId);\n dropdown.onChange(async (value) => {\n this.plugin.settings.defaultProviderId = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Default vault access\")\n .setDesc(\"How much permission the agent has when it proposes changes.\")\n .addDropdown((dropdown) => {\n dropdown\n .addOption(\"read-only\", \"Read only\")\n .addOption(\"confirm\", \"Confirm actions\")\n .addOption(\"full-access\", \"Full access\")\n .setValue(this.plugin.settings.defaultAccessMode)\n .onChange(async (value: AccessMode) => {\n this.plugin.settings.defaultAccessMode = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Include folders\")\n .setDesc(\"Comma-separated folder prefixes to include. Leave blank for the whole vault.\")\n .addText((text) => {\n text\n .setPlaceholder(\"Projects, Daily Notes\")\n .setValue(this.plugin.settings.includeFolders)\n .onChange(async (value) => {\n this.plugin.settings.includeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Exclude folders\")\n .setDesc(\"Comma-separated folder prefixes that should never be sent to an agent.\")\n .addText((text) => {\n text\n .setValue(this.plugin.settings.excludeFolders)\n .onChange(async (value) => {\n this.plugin.settings.excludeFolders = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Max context characters\")\n .setDesc(\"Upper bound for note text sent to the agent.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxContextChars))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed > 1000) {\n this.plugin.settings.maxContextChars = parsed;\n await this.plugin.saveSettings();\n }\n });\n });\n\n new Setting(containerEl)\n .setName(\"Conversation memory\")\n .setDesc(\"Remember previous sidebar messages after closing and reopening Obsidian.\")\n .addToggle((toggle) => {\n toggle\n .setValue(this.plugin.settings.enableConversationMemory)\n .onChange(async (value) => {\n this.plugin.settings.enableConversationMemory = value;\n await this.plugin.saveSettings();\n });\n });\n\n new Setting(containerEl)\n .setName(\"Memory length\")\n .setDesc(\"Maximum number of recent sidebar messages to remember.\")\n .addText((text) => {\n text\n .setValue(String(this.plugin.settings.maxMemoryMessages))\n .onChange(async (value) => {\n const parsed = Number(value);\n if (Number.isFinite(parsed) && parsed >= 2) {\n this.plugin.settings.maxMemoryMessages = parsed;\n await this.plugin.saveSettings();\n }\n });\n })\n .addButton((button) => {\n button.setButtonText(\"Clear memory\").onClick(async () => {\n await this.plugin.clearMemory();\n new Notice(\"AI Sidebar memory cleared.\");\n });\n });\n\n containerEl.createEl(\"h3\", { text: \"Connect a Provider\" });\n this.renderProviderPresets(containerEl);\n\n containerEl.createEl(\"h3\", { text: \"Connected Providers\" });\n for (const provider of this.plugin.settings.providers) {\n this.renderProviderSetting(containerEl, provider);\n }\n\n new Setting(containerEl)\n .setName(\"Advanced provider\")\n .setDesc(\"Create a custom API, OAuth, or local CLI provider.\")\n .addButton((button) => {\n button.setButtonText(\"Add custom\").onClick(async () => {\n const id = `provider-${Date.now()}`;\n this.plugin.settings.providers.push({ id, name: \"New provider\", authType: \"oauth\", command: \"\" });\n await this.plugin.saveSettings();\n this.display();\n });\n });\n }\n\n private renderProviderPresets(containerEl: HTMLElement) {\n const grid = containerEl.createDiv(\"ai-sidebar-provider-grid\");\n for (const preset of PROVIDER_PRESETS) {\n const provider = this.plugin.getProvider(preset.provider.id);\n const card = grid.createDiv(\"ai-sidebar-provider-card\");\n card.createDiv({ cls: \"ai-sidebar-provider-card__name\", text: preset.name });\n card.createDiv({ cls: \"ai-sidebar-provider-card__desc\", text: preset.description });\n const status = provider ? providerConnectionLabel(provider) : \"Not added\";\n card.createDiv({ cls: \"ai-sidebar-provider-card__status\", text: status });\n\n const actions = card.createDiv(\"ai-sidebar-provider-card__actions\");\n new ButtonComponent(actions)\n .setButtonText(provider ? \"Use\" : \"Add\")\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n this.display();\n });\n\n new ButtonComponent(actions)\n .setCta()\n .setButtonText(providerConnectionActionText(provider ?? preset.provider))\n .onClick(async () => {\n await this.plugin.upsertProviderFromPreset(preset);\n const connectedProvider = this.plugin.getProvider(preset.provider.id) ?? preset.provider;\n await startProviderSignIn(connectedProvider);\n this.display();\n });\n }\n }\n\n private providerOptions(): Record<string, string> {\n return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {\n options[provider.id] = provider.name || provider.id;\n return options;\n }, {});\n }\n\n private renderProviderSetting(containerEl: HTMLElement, provider: AgentProvider) {\n const wrapper = containerEl.createDiv(\"ai-sidebar-settings-provider\");\n new Setting(wrapper)\n .setName(provider.name || provider.id)\n .setDesc(this.providerDescription(provider))\n .addText((text) => {\n text\n .setPlaceholder(\"Name\")\n .setValue(provider.name)\n .onChange(async (value) => {\n provider.name = value;\n await this.plugin.saveSettings();\n });\n })\n .addText((text) => {\n text\n .setPlaceholder(\"id\")\n .setValue(provider.id)\n .onChange(async (value) => {\n provider.id = sanitizeProviderId(value);\n await this.plugin.saveSettings();\n });\n })\n .addDropdown((dropdown) => {\n dropdown\n .addOption(\"api-key\", \"API key\")\n .addOption(\"oauth\", \"OAuth\")\n .addOption(\"cli\", \"Local CLI\")\n .setValue(provider.authType)\n .onChange(async (value: ProviderAuthType) => {\n provider.authType = value;\n await this.plugin.saveSettings();\n this.display();\n });\n })\n .addButton((button) => {\n this.configureConnectButton(button, provider);\n })\n .addText((text) => {\n if (provider.authType === \"cli\") {\n text\n .setPlaceholder(\"codex\")\n .setValue(provider.command)\n .onChange(async (value) => {\n provider.command = value;\n await this.plugin.saveSettings();\n });\n } else {\n text.inputEl.hide();\n }\n })\n .addButton((button) => {\n button\n .setIcon(\"trash\")\n .setTooltip(\"Remove provider\")\n .onClick(async () => {\n this.plugin.settings.providers = this.plugin.settings.providers.filter((item) => item !== provider);\n if (this.plugin.settings.defaultProviderId === provider.id) {\n this.plugin.settings.defaultProviderId = this.plugin.settings.providers.first()?.id ?? \"\";\n }\n await this.plugin.saveSettings();\n this.display();\n });\n });\n\n if (provider.authType === \"api-key\") {\n new Setting(wrapper)\n .setName(\"API connection\")\n .setDesc(\"OpenAI-compatible Responses API endpoint, models, reasoning effort, and API key.\")\n .addText((text) => {\n text\n .setPlaceholder(\"https://api.openai.com/v1/responses\")\n .setValue(provider.apiBaseUrl ?? \"\")\n .onChange(async (value) => {\n provider.apiBaseUrl = value;\n await this.plugin.saveSettings();\n });\n })\n .addText((text) => {\n text\n .setPlaceholder(\"model\")\n .setValue(provider.model ?? \"\")\n .onChange(async (value) => {\n provider.model = value;\n await this.plugin.saveSettings();\n });\n })\n .addText((text) => {\n text\n .setPlaceholder(\"models for /model, comma-separated\")\n .setValue(provider.models ?? \"\")\n .onChange(async (value) => {\n provider.models = value;\n await this.plugin.saveSettings();\n });\n })\n .addDropdown((dropdown) => {\n dropdown\n .addOption(\"low\", \"Low\")\n .addOption(\"medium\", \"Medium\")\n .addOption(\"high\", \"High\")\n .setValue(provider.reasoningEffort ?? \"medium\")\n .onChange(async (value: ReasoningEffort) => {\n provider.reasoningEffort = value;\n await this.plugin.saveSettings();\n });\n })\n .addText((text) => {\n text\n .setPlaceholder(\"API key\")\n .setValue(provider.apiKey ?? \"\")\n .onChange(async (value) => {\n provider.apiKey = value;\n await this.plugin.saveSettings();\n });\n text.inputEl.type = \"password\";\n });\n }\n\n if (provider.authType === \"oauth\") {\n new Setting(wrapper)\n .setName(\"OAuth connection\")\n .setDesc(\"Use this for providers that publish OAuth endpoints for desktop apps.\")\n .addText((text) => {\n text\n .setPlaceholder(\"Authorization URL\")\n .setValue(provider.authorizationUrl ?? \"\")\n .onChange(async (value) => {\n provider.authorizationUrl = value;\n await this.plugin.saveSettings();\n });\n })\n .addText((text) => {\n text\n .setPlaceholder(\"Client ID\")\n .setValue(provider.clientId ?? \"\")\n .onChange(async (value) => {\n provider.clientId = value;\n await this.plugin.saveSettings();\n });\n })\n .addText((text) => {\n text\n .setPlaceholder(\"Scope\")\n .setValue(provider.scope ?? \"\")\n .onChange(async (value) => {\n provider.scope = value;\n await this.plugin.saveSettings();\n });\n })\n .addText((text) => {\n text\n .setPlaceholder(\"Access token\")\n .setValue(provider.accessToken ?? \"\")\n .onChange(async (value) => {\n provider.accessToken = value;\n await this.plugin.saveSettings();\n });\n text.inputEl.type = \"password\";\n });\n }\n }\n\n private providerDescription(provider: AgentProvider): string {\n if (provider.authType === \"api-key\") {\n return provider.apiKey ? \"Connected with API key.\" : \"Add an API key to connect.\";\n }\n if (provider.authType === \"oauth\") {\n return provider.accessToken ? \"Connected with OAuth.\" : \"Connect with OAuth when the provider exposes a desktop OAuth app.\";\n }\n return provider.command ? `Runs local command: ${provider.command}` : \"Add the local CLI command.\";\n }\n\n private configureConnectButton(button: ButtonComponent, provider: AgentProvider) {\n button\n .setButtonText(providerConnectionActionText(provider))\n .onClick(() => {\n void startProviderSignIn(provider);\n });\n }\n}\n\nclass ConfirmActionsModal extends Modal {\n private actions: VaultAction[];\n private resolve: (approved: boolean) => void;\n\n constructor(app: App, actions: VaultAction[], resolve: (approved: boolean) => void) {\n super(app);\n this.actions = actions;\n this.resolve = resolve;\n }\n\n onOpen() {\n const { contentEl } = this;\n contentEl.empty();\n contentEl.createEl(\"h2\", { text: \"Apply AI vault actions?\" });\n contentEl.createEl(\"p\", { text: \"Review these proposed file changes before they are applied.\" });\n\n for (const action of this.actions) {\n const actionEl = contentEl.createDiv(\"ai-sidebar-action-preview\");\n actionEl.createEl(\"strong\", { text: `${action.type}: ${action.path}` });\n if (action.type === \"rename\") {\n actionEl.createDiv({ text: `New path: ${action.newPath}` });\n }\n if (\"content\" in action) {\n actionEl.createEl(\"pre\", { text: action.content.slice(0, 1200) });\n }\n }\n\n new Setting(contentEl)\n .addButton((button) => {\n button.setButtonText(\"Cancel\").onClick(() => {\n this.resolve(false);\n this.close();\n });\n })\n .addButton((button) => {\n button.setCta().setButtonText(\"Apply\").onClick(() => {\n this.resolve(true);\n this.close();\n });\n });\n }\n\n onClose() {\n this.contentEl.empty();\n }\n}\n\nasync function collectVaultContext(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n selectedSkillNames: string[] = [],\n): Promise<VaultContext> {\n const activeView = app.workspace.getActiveViewOfType(MarkdownView);\n const activeFile = activeView?.file ?? app.workspace.getActiveFile();\n const selection = activeView?.editor.getSelection() || undefined;\n const budget = new ContextBudget(settings.maxContextChars);\n\n const context: VaultContext = {\n selection,\n linkedFiles: [],\n backlinkFiles: [],\n relevantFiles: [],\n baseFiles: [],\n selectedSkills: [],\n };\n\n const activeContext = activeFile ? await readContextFile(app, activeFile, \"active note\", budget) : undefined;\n if (activeContext) context.activeFile = activeContext;\n\n const linked = activeFile ? getLinkedFiles(app, activeFile) : [];\n for (const file of linked) {\n const item = await readContextFile(app, file, \"linked note\", budget);\n if (item) context.linkedFiles.push(item);\n }\n\n const backlinks = activeFile ? getBacklinkFiles(app, activeFile) : [];\n for (const file of backlinks) {\n const item = await readContextFile(app, file, \"backlink\", budget);\n if (item) context.backlinkFiles.push(item);\n }\n\n const baseFiles = app.vault\n .getFiles()\n .filter((file) => file.extension === \"base\" && shouldIncludeFile(file, settings))\n .slice(0, 5);\n for (const file of baseFiles) {\n const item = await readContextFile(app, file, \"base file\", budget);\n if (item) context.baseFiles.push(item);\n }\n\n const relatedFiles = await findRelevantFiles(app, settings, prompt, activeFile, budget.remaining());\n for (const file of relatedFiles) {\n const item = await readContextFile(app, file, \"relevant match\", budget);\n if (item) context.relevantFiles.push(item);\n }\n\n context.selectedSkills = await readSelectedSkills(app, selectedSkillNames, budget);\n\n return context;\n}\n\nasync function readContextFile(\n app: App,\n file: TFile,\n reason: string,\n budget: ContextBudget,\n): Promise<VaultContextFile | undefined> {\n if (!budget.hasRoom()) return undefined;\n const content = await app.vault.cachedRead(file);\n const clipped = budget.take(content);\n return { path: file.path, content: clipped, reason };\n}\n\nfunction getLinkedFiles(app: App, activeFile: TFile): TFile[] {\n const cache = app.metadataCache.getFileCache(activeFile);\n const links = cache?.links ?? [];\n const files: TFile[] = [];\n\n for (const link of links) {\n const linkedFile = app.metadataCache.getFirstLinkpathDest(link.link, activeFile.path);\n if (linkedFile) files.push(linkedFile);\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nfunction getBacklinkFiles(app: App, activeFile: TFile): TFile[] {\n const resolvedLinks = app.metadataCache.resolvedLinks;\n const files: TFile[] = [];\n\n for (const [sourcePath, targets] of Object.entries(resolvedLinks)) {\n if (targets[activeFile.path]) {\n const file = app.vault.getAbstractFileByPath(sourcePath);\n if (file instanceof TFile) files.push(file);\n }\n }\n\n return uniqueFiles(files).slice(0, 8);\n}\n\nasync function findRelevantFiles(\n app: App,\n settings: AISidebarSettings,\n prompt: string,\n activeFile: TFile | null,\n maxChars: number,\n): Promise<TFile[]> {\n if (!prompt.trim() || maxChars <= 0) return [];\n const terms = prompt\n .toLowerCase()\n .split(/[^a-z0-9/_-]+/i)\n .filter((term) => term.length > 2)\n .slice(0, 16);\n if (terms.length === 0) return [];\n\n const scored: Array<{ file: TFile; score: number }> = [];\n const files = app.vault\n .getFiles()\n .filter((file) => file !== activeFile && shouldIncludeFile(file, settings) && isReadableContextFile(file));\n\n for (const file of files) {\n const content = (await app.vault.cachedRead(file)).toLowerCase();\n const path = file.path.toLowerCase();\n let score = 0;\n for (const term of terms) {\n if (path.includes(term)) score += 4;\n const matches = content.match(new RegExp(escapeRegExp(term), \"g\"));\n score += matches ? Math.min(matches.length, 8) : 0;\n }\n if (score > 0) scored.push({ file, score });\n }\n\n return scored\n .sort((a, b) => b.score - a.score)\n .slice(0, 10)\n .map((item) => item.file);\n}\n\nfunction shouldIncludeFile(file: TFile, settings: AISidebarSettings): boolean {\n const includes = parseFolderList(settings.includeFolders);\n const excludes = parseFolderList(settings.excludeFolders);\n if (includes.length > 0 && !includes.some((folder) => file.path.startsWith(folder))) return false;\n if (excludes.some((folder) => file.path.startsWith(folder))) return false;\n return isReadableContextFile(file);\n}\n\nfunction isReadableContextFile(file: TFile): boolean {\n return [\"md\", \"canvas\", \"base\", \"json\", \"txt\"].includes(file.extension);\n}\n\nfunction nativeSlashCommands(\n provider: AgentProvider,\n query: string,\n currentAccessMode: AccessMode,\n): Array<{ label: string; description: string; insert: string }> {\n const commands: Array<{ label: string; description: string; insert: string }> = [];\n const modelChoices = parseProviderModels(provider);\n\n for (const model of modelChoices) {\n commands.push({\n label: `/model:${model}`,\n description: `Use ${model} for this request`,\n insert: `/model:${model}`,\n });\n }\n\n if (provider.authType === \"api-key\" || provider.id.includes(\"codex\") || provider.id.includes(\"openai\")) {\n for (const effort of [\"low\", \"medium\", \"high\"] as ReasoningEffort[]) {\n commands.push({\n label: `/reasoning:${effort}`,\n description: `Set reasoning effort to ${effort}`,\n insert: `/reasoning:${effort}`,\n });\n }\n }\n\n for (const mode of [\"read-only\", \"confirm\", \"full-access\"] as AccessMode[]) {\n commands.push({\n label: `/access:${mode}`,\n description: mode === currentAccessMode ? \"Current vault access mode\" : `Use ${mode} for this request`,\n insert: `/access:${mode}`,\n });\n }\n\n commands.push(\n { label: \"/memory:on\", description: \"Include remembered chat history for this request\", insert: \"/memory:on\" },\n { label: \"/memory:off\", description: \"Do not include remembered chat history for this request\", insert: \"/memory:off\" },\n );\n\n const normalizedQuery = query.toLowerCase();\n return commands.filter((command) => command.label.slice(1).toLowerCase().includes(normalizedQuery));\n}\n\nfunction parseProviderModels(provider: AgentProvider): string[] {\n const configured = provider.models\n ?.split(\",\")\n .map((model) => model.trim())\n .filter(Boolean) ?? [];\n const models = configured.length > 0 ? configured : [provider.model].filter((model): model is string => Boolean(model));\n return Array.from(new Set(models)).slice(0, 12);\n}\n\nasync function listAgentSkills(app: App, query = \"\"): Promise<Array<{ name: string; path: string }>> {\n const normalizedQuery = query.toLowerCase();\n const skills = app.vault\n .getFiles()\n .filter((file) => isSkillFile(file))\n .map((file) => ({\n name: skillNameFromPath(file.path),\n path: file.path,\n }))\n .filter((skill) => !normalizedQuery || skill.name.toLowerCase().includes(normalizedQuery))\n .sort((a, b) => a.name.localeCompare(b.name));\n\n const seen = new Set<string>();\n return skills.filter((skill) => {\n const key = skill.name.toLowerCase();\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\nasync function readSelectedSkills(\n app: App,\n selectedSkillNames: string[],\n budget: ContextBudget,\n): Promise<AgentSkill[]> {\n const names = new Set(selectedSkillNames.map((name) => name.toLowerCase()));\n if (names.size === 0) return [];\n\n const skills: AgentSkill[] = [];\n for (const file of app.vault.getFiles().filter((candidate) => isSkillFile(candidate))) {\n const name = skillNameFromPath(file.path);\n if (!names.has(name.toLowerCase()) || !budget.hasRoom()) continue;\n const content = await app.vault.cachedRead(file);\n skills.push({\n name,\n path: file.path,\n content: budget.take(content),\n });\n }\n return skills;\n}\n\nfunction isSkillFile(file: TFile): boolean {\n if (![\"md\", \"txt\", \"json\", \"yaml\", \"yml\"].includes(file.extension)) return false;\n return file.path.startsWith(\".agent/skills/\") || file.path.startsWith(\".agents/skills/\");\n}\n\nfunction skillNameFromPath(path: string): string {\n const parts = path.split(\"/\");\n const fileName = parts.last() ?? path;\n if (fileName.toLowerCase() === \"skill.md\" && parts.length > 1) {\n return parts[parts.length - 2];\n }\n return fileName.replace(/\\.[^.]+$/, \"\");\n}\n\nfunction parseFolderList(value: string): string[] {\n return value\n .split(\",\")\n .map((folder) => folder.trim().replace(/^\\/+/, \"\"))\n .filter(Boolean);\n}\n\nclass ContextBudget {\n private remainingChars: number;\n\n constructor(maxChars: number) {\n this.remainingChars = maxChars;\n }\n\n hasRoom(): boolean {\n return this.remainingChars > 0;\n }\n\n remaining(): number {\n return this.remainingChars;\n }\n\n take(content: string): string {\n const clipped = content.slice(0, this.remainingChars);\n this.remainingChars -= clipped.length;\n return clipped;\n }\n}\n\nfunction buildAgentInstructions(accessMode: AccessMode): string {\n return [\n \"You are running inside an Obsidian AI Sidebar plugin.\",\n \"Use the provided vault context to answer the user.\",\n \"If the prompt contains slash commands such as /writer or /reviewer, apply the matching selectedSkills instructions from the request context.\",\n \"The request may include conversation memory and per-request options such as model or reasoning effort.\",\n \"When you need file changes, include a JSON code block with this shape:\",\n '{\"actions\":[{\"type\":\"edit\",\"path\":\"Note.md\",\"content\":\"new content\"}]}',\n \"Allowed action types: create, edit, append, delete, rename.\",\n `Current access mode: ${accessMode}. The plugin enforces this mode before applying actions.`,\n ].join(\"\\n\");\n}\n\nasync function runProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (provider.authType === \"api-key\") {\n return runOpenAICompatibleProvider(provider, request);\n }\n\n if (provider.authType === \"oauth\") {\n if (!provider.accessToken) {\n throw new Error(\"This OAuth provider is not connected yet.\");\n }\n return runOAuthProvider(provider, request);\n }\n\n return runCliProvider(provider.command, request);\n}\n\nasync function runOpenAICompatibleProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiKey) throw new Error(\"Missing API key.\");\n const url = provider.apiBaseUrl || \"https://api.openai.com/v1/responses\";\n const model = request.options.model || provider.model || \"gpt-4.1-mini\";\n const userPayload = JSON.stringify({\n prompt: request.prompt,\n accessMode: request.accessMode,\n context: request.context,\n options: request.options,\n });\n const messages = [\n { role: \"system\", content: request.instructions },\n ...request.conversation.map((message) => ({\n role: message.role === \"assistant\" ? \"assistant\" : \"user\",\n content: message.content,\n })),\n { role: \"user\", content: userPayload },\n ];\n const usesChatCompletions = url.includes(\"/chat/completions\");\n const body: Record<string, unknown> = usesChatCompletions\n ? { model, messages }\n : { model, input: messages };\n if (request.options.reasoningEffort && model.startsWith(\"o\")) {\n body.reasoning = { effort: request.options.reasoningEffort };\n }\n const response = await requestUrl({\n url,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n return extractOpenAIText(response.json);\n}\n\nasync function runOAuthProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {\n if (!provider.apiBaseUrl) {\n throw new Error(\"OAuth provider needs an API endpoint after login.\");\n }\n const response = await requestUrl({\n url: provider.apiBaseUrl,\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${provider.accessToken}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(request),\n throw: false,\n });\n\n if (response.status < 200 || response.status >= 300) {\n throw new Error(response.text || `HTTP ${response.status}`);\n }\n\n if (typeof response.json?.text === \"string\") return response.json.text;\n if (typeof response.json?.output_text === \"string\") return response.json.output_text;\n return response.text;\n}\n\nfunction runCliProvider(command: string, request: AgentRequest): Promise<string> {\n return new Promise((resolve, reject) => {\n const child = spawn(command, {\n shell: true,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n\n let stdout = \"\";\n let stderr = \"\";\n\n child.stdout.on(\"data\", (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n\n child.stderr.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n if (code === 0) {\n resolve(stdout.trim());\n } else {\n reject(new Error(stderr.trim() || `Command exited with code ${code}`));\n }\n });\n\n child.stdin.write(JSON.stringify(request, null, 2));\n child.stdin.end();\n });\n}\n\nfunction runShellCommand(command: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const child = spawn(command, {\n detached: true,\n shell: true,\n stdio: \"ignore\",\n });\n child.on(\"error\", reject);\n child.unref();\n resolve();\n });\n}\n\nfunction extractOpenAIText(json: unknown): string {\n if (!json || typeof json !== \"object\") return \"\";\n const response = json as {\n output_text?: string;\n output?: Array<{ content?: Array<{ text?: string; type?: string }> }>;\n choices?: Array<{ message?: { content?: string } }>;\n };\n if (typeof response.output_text === \"string\") return response.output_text;\n if (typeof response.choices?.[0]?.message?.content === \"string\") {\n return response.choices[0].message.content;\n }\n const parts: string[] = [];\n for (const item of response.output ?? []) {\n for (const content of item.content ?? []) {\n if (typeof content.text === \"string\") parts.push(content.text);\n }\n }\n return parts.join(\"\\n\");\n}\n\nasync function startOAuthLogin(provider: AgentProvider): Promise<void> {\n if (!provider.authorizationUrl || !provider.clientId) {\n new Notice(\"Add an authorization URL and client ID first.\");\n return;\n }\n\n const url = new URL(provider.authorizationUrl);\n url.searchParams.set(\"response_type\", \"token\");\n url.searchParams.set(\"client_id\", provider.clientId);\n url.searchParams.set(\"redirect_uri\", \"urn:ietf:wg:oauth:2.0:oob\");\n if (provider.scope) url.searchParams.set(\"scope\", provider.scope);\n url.searchParams.set(\"state\", crypto.randomUUID());\n\n window.open(url.toString());\n new Notice(\"OAuth sign-in opened in your browser. Paste the returned token into this provider when available.\");\n}\n\nasync function startProviderSignIn(provider: AgentProvider): Promise<void> {\n if (provider.authType === \"cli\") {\n if (!provider.signInCommand) {\n new Notice(\"This local provider does not have a sign-in command configured.\");\n return;\n }\n await runShellCommand(provider.signInCommand);\n new Notice(`Started ${provider.name} sign-in. Follow the browser or terminal prompt.`);\n return;\n }\n\n if (provider.authType === \"api-key\") {\n if (provider.setupUrl) window.open(provider.setupUrl);\n new Notice(provider.apiKey ? `${provider.name} is connected.` : `Add your ${provider.name} API key below to connect.`);\n return;\n }\n\n await startOAuthLogin(provider);\n}\n\nfunction providerConnectionActionText(provider: AgentProvider): string {\n if (provider.authType === \"cli\") return \"Sign in\";\n if (provider.authType === \"api-key\") return provider.apiKey ? \"Connected\" : \"Get key\";\n return provider.accessToken ? \"Reconnect\" : \"Sign in\";\n}\n\nfunction providerConnectionLabel(provider: AgentProvider): string {\n if (provider.authType === \"api-key\") return provider.apiKey ? \"Connected\" : \"Needs API key\";\n if (provider.authType === \"oauth\") return provider.accessToken ? \"Connected\" : \"Needs sign-in\";\n return provider.signInCommand ? \"Sign-in available\" : \"CLI command\";\n}\n\nfunction isProviderReady(provider: AgentProvider): boolean {\n if (provider.authType === \"api-key\") return Boolean(provider.apiKey && provider.apiBaseUrl);\n if (provider.authType === \"oauth\") return Boolean(provider.accessToken && provider.apiBaseUrl);\n return Boolean(provider.command.trim());\n}\n\nfunction normalizeProviders(providers: AgentProvider[]): AgentProvider[] {\n return providers.map((provider) => {\n const migrated = provider as AgentProvider;\n return {\n ...migrated,\n authType: migrated.authType ?? (migrated.command ? \"cli\" : \"api-key\"),\n command: migrated.command ?? \"\",\n };\n });\n}\n\nfunction cloneProvider(provider: AgentProvider): AgentProvider {\n return { ...provider };\n}\n\nfunction extractVaultActions(response: string): VaultAction[] {\n const actions: VaultAction[] = [];\n const codeBlockPattern = /```(?:json)?\\s*([\\s\\S]*?)```/gi;\n const candidates: string[] = [];\n let match: RegExpExecArray | null;\n\n while ((match = codeBlockPattern.exec(response)) !== null) {\n candidates.push(match[1]);\n }\n candidates.push(response);\n\n for (const candidate of candidates) {\n try {\n const parsed = JSON.parse(candidate.trim()) as { actions?: VaultAction[] };\n if (Array.isArray(parsed.actions)) {\n actions.push(...parsed.actions.filter(isVaultAction));\n }\n } catch {\n continue;\n }\n }\n\n return actions;\n}\n\nfunction isVaultAction(value: unknown): value is VaultAction {\n if (!value || typeof value !== \"object\") return false;\n const action = value as Partial<VaultAction>;\n if (typeof action.type !== \"string\" || typeof action.path !== \"string\") return false;\n if ([\"create\", \"edit\", \"append\"].includes(action.type)) {\n return typeof (action as { content?: unknown }).content === \"string\";\n }\n if (action.type === \"delete\") return true;\n if (action.type === \"rename\") {\n return typeof (action as { newPath?: unknown }).newPath === \"string\";\n }\n return false;\n}\n\nfunction confirmVaultActions(app: App, actions: VaultAction[]): Promise<boolean> {\n return new Promise((resolve) => {\n new ConfirmActionsModal(app, actions, resolve).open();\n });\n}\n\nasync function applyVaultAction(app: App, action: VaultAction): Promise<void> {\n const path = normalizeVaultPath(action.path);\n const file = app.vault.getAbstractFileByPath(path);\n\n if (action.type === \"create\") {\n if (file) throw new Error(\"File already exists.\");\n await ensureParentFolder(app, path);\n await app.vault.create(path, action.content);\n return;\n }\n\n if (!(file instanceof TFile)) {\n throw new Error(\"File does not exist.\");\n }\n\n if (action.type === \"edit\") {\n await app.vault.modify(file, action.content);\n } else if (action.type === \"append\") {\n const current = await app.vault.read(file);\n await app.vault.modify(file, `${current}${current.endsWith(\"\\n\") ? \"\" : \"\\n\"}${action.content}`);\n } else if (action.type === \"delete\") {\n await app.vault.delete(file);\n } else if (action.type === \"rename\") {\n await ensureParentFolder(app, action.newPath);\n await app.fileManager.renameFile(file, normalizeVaultPath(action.newPath));\n }\n}\n\nasync function ensureParentFolder(app: App, path: string): Promise<void> {\n const parts = normalizeVaultPath(path).split(\"/\");\n parts.pop();\n let current = \"\";\n for (const part of parts) {\n current = current ? `${current}/${part}` : part;\n if (!app.vault.getAbstractFileByPath(current)) {\n await app.vault.createFolder(current);\n }\n }\n}\n\nfunction normalizeVaultPath(path: string): string {\n return path.replace(/^\\/+/, \"\").replace(/\\\\/g, \"/\");\n}\n\nfunction uniqueFiles(files: TFile[]): TFile[] {\n const seen = new Set<string>();\n return files.filter((file) => {\n if (seen.has(file.path)) return false;\n seen.add(file.path);\n return true;\n });\n}\n\nfunction sanitizeProviderId(value: string): string {\n return value.toLowerCase().replace(/[^a-z0-9_-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || \"provider\";\n}\n\nfunction isReasoningEffort(value: string): value is ReasoningEffort {\n return value === \"low\" || value === \"medium\" || value === \"high\";\n}\n\nfunction isAccessMode(value: string): value is AccessMode {\n return value === \"read-only\" || value === \"confirm\" || value === \"full-access\";\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"}}}}