2026-04-29 11:20:13 +00:00
|
|
|
import {
|
|
|
|
|
App,
|
|
|
|
|
ButtonComponent,
|
|
|
|
|
DropdownComponent,
|
|
|
|
|
ItemView,
|
|
|
|
|
MarkdownView,
|
|
|
|
|
Modal,
|
|
|
|
|
Notice,
|
|
|
|
|
Plugin,
|
|
|
|
|
PluginSettingTab,
|
|
|
|
|
requestUrl,
|
|
|
|
|
Setting,
|
|
|
|
|
TFile,
|
|
|
|
|
WorkspaceLeaf,
|
|
|
|
|
} from "obsidian";
|
|
|
|
|
import { spawn } from "child_process";
|
2026-04-29 11:54:01 +00:00
|
|
|
import { promises as fs } from "fs";
|
|
|
|
|
import { homedir } from "os";
|
|
|
|
|
import * as path from "path";
|
2026-04-29 11:20:13 +00:00
|
|
|
|
|
|
|
|
const VIEW_TYPE_AI_SIDEBAR = "ai-sidebar-view";
|
|
|
|
|
|
|
|
|
|
type AccessMode = "read-only" | "confirm" | "full-access";
|
|
|
|
|
type ProviderAuthType = "api-key" | "oauth" | "cli";
|
|
|
|
|
type ReasoningEffort = "low" | "medium" | "high";
|
|
|
|
|
|
|
|
|
|
interface AgentProvider {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
authType: ProviderAuthType;
|
|
|
|
|
command: string;
|
|
|
|
|
signInCommand?: string;
|
|
|
|
|
setupUrl?: string;
|
2026-04-29 11:39:23 +00:00
|
|
|
connectedAt?: number;
|
2026-04-29 11:20:13 +00:00
|
|
|
apiBaseUrl?: string;
|
|
|
|
|
apiKey?: string;
|
|
|
|
|
model?: string;
|
|
|
|
|
models?: string;
|
|
|
|
|
reasoningEffort?: ReasoningEffort;
|
|
|
|
|
authorizationUrl?: string;
|
|
|
|
|
tokenUrl?: string;
|
|
|
|
|
clientId?: string;
|
|
|
|
|
scope?: string;
|
|
|
|
|
accessToken?: string;
|
|
|
|
|
refreshToken?: string;
|
|
|
|
|
tokenExpiresAt?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AISidebarSettings {
|
|
|
|
|
providers: AgentProvider[];
|
|
|
|
|
defaultProviderId: string;
|
|
|
|
|
defaultAccessMode: AccessMode;
|
|
|
|
|
includeFolders: string;
|
|
|
|
|
excludeFolders: string;
|
|
|
|
|
maxContextChars: number;
|
|
|
|
|
enableConversationMemory: boolean;
|
|
|
|
|
maxMemoryMessages: number;
|
|
|
|
|
rememberedMessages: ChatMessage[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VaultContextFile {
|
|
|
|
|
path: string;
|
|
|
|
|
content: string;
|
|
|
|
|
reason: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AgentSkill {
|
|
|
|
|
name: string;
|
|
|
|
|
path: string;
|
|
|
|
|
content: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:54:01 +00:00
|
|
|
interface SkillReference {
|
|
|
|
|
name: string;
|
|
|
|
|
path: string;
|
|
|
|
|
source: "vault" | "global";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
interface VaultContext {
|
|
|
|
|
activeFile?: VaultContextFile;
|
|
|
|
|
selection?: string;
|
|
|
|
|
linkedFiles: VaultContextFile[];
|
|
|
|
|
backlinkFiles: VaultContextFile[];
|
|
|
|
|
relevantFiles: VaultContextFile[];
|
|
|
|
|
baseFiles: VaultContextFile[];
|
|
|
|
|
selectedSkills: AgentSkill[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ChatMessage {
|
|
|
|
|
role: "user" | "assistant" | "system";
|
|
|
|
|
content: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AgentRequest {
|
|
|
|
|
prompt: string;
|
|
|
|
|
accessMode: AccessMode;
|
|
|
|
|
context: VaultContext;
|
|
|
|
|
instructions: string;
|
|
|
|
|
conversation: ChatMessage[];
|
|
|
|
|
options: AgentRequestOptions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AgentRequestOptions {
|
|
|
|
|
model?: string;
|
|
|
|
|
reasoningEffort?: ReasoningEffort;
|
|
|
|
|
memoryEnabled: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ParsedPrompt {
|
|
|
|
|
cleanPrompt: string;
|
|
|
|
|
selectedSkillNames: string[];
|
|
|
|
|
options: AgentRequestOptions;
|
|
|
|
|
accessMode?: AccessMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type VaultAction =
|
|
|
|
|
| { type: "create"; path: string; content: string }
|
|
|
|
|
| { type: "edit"; path: string; content: string }
|
|
|
|
|
| { type: "append"; path: string; content: string }
|
|
|
|
|
| { type: "delete"; path: string }
|
|
|
|
|
| { type: "rename"; path: string; newPath: string };
|
|
|
|
|
|
|
|
|
|
interface ProviderPreset {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
description: string;
|
|
|
|
|
provider: AgentProvider;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PROVIDER_PRESETS: ProviderPreset[] = [
|
|
|
|
|
{
|
|
|
|
|
id: "codex",
|
|
|
|
|
name: "Codex",
|
|
|
|
|
description: "Sign in with Codex locally and use the Codex CLI agent.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "codex",
|
|
|
|
|
name: "Codex",
|
|
|
|
|
authType: "cli",
|
2026-04-29 11:39:23 +00:00
|
|
|
command: "codex exec",
|
2026-04-29 11:20:13 +00:00
|
|
|
signInCommand: "codex login",
|
|
|
|
|
models: "gpt-5.4, gpt-5.4-mini, gpt-5.3-codex",
|
|
|
|
|
reasoningEffort: "medium",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "claude",
|
|
|
|
|
name: "Claude Code",
|
|
|
|
|
description: "Sign in with your Claude subscription or Anthropic Console account.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "claude",
|
|
|
|
|
name: "Claude Code",
|
|
|
|
|
authType: "cli",
|
|
|
|
|
command: "claude",
|
|
|
|
|
signInCommand: "claude auth login",
|
|
|
|
|
setupUrl: "https://code.claude.com/docs/en/cli-usage",
|
|
|
|
|
models: "opus, sonnet, haiku",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "opencode",
|
|
|
|
|
name: "opencode",
|
|
|
|
|
description: "Connect opencode to any provider it supports, including subscription/API accounts.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "opencode",
|
|
|
|
|
name: "opencode",
|
|
|
|
|
authType: "cli",
|
|
|
|
|
command: "opencode",
|
|
|
|
|
signInCommand: "opencode auth login",
|
|
|
|
|
setupUrl: "https://opencode.ai/docs/cli/",
|
|
|
|
|
models: "anthropic/claude-sonnet-4-5, openai/gpt-5.4, google/gemini-2.5-pro",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "openai",
|
|
|
|
|
name: "OpenAI API",
|
|
|
|
|
description: "Use an OpenAI API key with the Responses API.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "openai",
|
|
|
|
|
name: "OpenAI",
|
|
|
|
|
authType: "api-key",
|
|
|
|
|
command: "",
|
|
|
|
|
setupUrl: "https://platform.openai.com/api-keys",
|
|
|
|
|
apiBaseUrl: "https://api.openai.com/v1/responses",
|
|
|
|
|
model: "gpt-4.1-mini",
|
|
|
|
|
models: "gpt-4.1-mini, gpt-4.1, o4-mini",
|
|
|
|
|
reasoningEffort: "medium",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "anthropic-api",
|
|
|
|
|
name: "Anthropic API",
|
|
|
|
|
description: "Use an Anthropic API key directly through an OpenAI-compatible proxy endpoint.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "anthropic-api",
|
|
|
|
|
name: "Anthropic API",
|
|
|
|
|
authType: "api-key",
|
|
|
|
|
command: "",
|
|
|
|
|
setupUrl: "https://console.anthropic.com/settings/keys",
|
|
|
|
|
apiBaseUrl: "",
|
|
|
|
|
model: "claude-sonnet-4-5",
|
|
|
|
|
models: "claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "gemini-cli",
|
|
|
|
|
name: "Gemini CLI",
|
|
|
|
|
description: "Use Google Gemini CLI with its native sign-in and subscription/API setup.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "gemini-cli",
|
|
|
|
|
name: "Gemini CLI",
|
|
|
|
|
authType: "cli",
|
|
|
|
|
command: "gemini",
|
|
|
|
|
signInCommand: "gemini",
|
|
|
|
|
setupUrl: "https://google-gemini.github.io/gemini-cli/docs/",
|
|
|
|
|
models: "gemini-2.5-pro, gemini-2.5-flash",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "openrouter",
|
|
|
|
|
name: "OpenRouter",
|
|
|
|
|
description: "Use one API key for many model providers through an OpenAI-compatible endpoint.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "openrouter",
|
|
|
|
|
name: "OpenRouter",
|
|
|
|
|
authType: "api-key",
|
|
|
|
|
command: "",
|
|
|
|
|
setupUrl: "https://openrouter.ai/settings/keys",
|
|
|
|
|
apiBaseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
|
|
|
|
model: "openai/gpt-4.1-mini",
|
|
|
|
|
models: "openai/gpt-4.1-mini, anthropic/claude-sonnet-4.5, google/gemini-2.5-pro",
|
|
|
|
|
reasoningEffort: "medium",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "deepseek",
|
|
|
|
|
name: "DeepSeek",
|
|
|
|
|
description: "Use DeepSeek with an API key and OpenAI-compatible endpoint.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "deepseek",
|
|
|
|
|
name: "DeepSeek",
|
|
|
|
|
authType: "api-key",
|
|
|
|
|
command: "",
|
|
|
|
|
setupUrl: "https://platform.deepseek.com/api_keys",
|
|
|
|
|
apiBaseUrl: "https://api.deepseek.com/v1/chat/completions",
|
|
|
|
|
model: "deepseek-chat",
|
|
|
|
|
models: "deepseek-chat, deepseek-reasoner",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "mistral",
|
|
|
|
|
name: "Mistral",
|
|
|
|
|
description: "Use Mistral models with an API key and OpenAI-compatible endpoint.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "mistral",
|
|
|
|
|
name: "Mistral",
|
|
|
|
|
authType: "api-key",
|
|
|
|
|
command: "",
|
|
|
|
|
setupUrl: "https://console.mistral.ai/api-keys",
|
|
|
|
|
apiBaseUrl: "https://api.mistral.ai/v1/chat/completions",
|
|
|
|
|
model: "mistral-large-latest",
|
|
|
|
|
models: "mistral-large-latest, codestral-latest, ministral-8b-latest",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "aider",
|
|
|
|
|
name: "Aider",
|
|
|
|
|
description: "Use Aider with keys from OpenAI, Anthropic, Gemini, OpenRouter, DeepSeek, and more.",
|
|
|
|
|
provider: {
|
|
|
|
|
id: "aider",
|
|
|
|
|
name: "Aider",
|
|
|
|
|
authType: "cli",
|
|
|
|
|
command: "aider",
|
|
|
|
|
signInCommand: "aider",
|
|
|
|
|
setupUrl: "https://aider.chat/docs/config/api-keys.html",
|
|
|
|
|
models: "sonnet, opus, gpt-4.1, o4-mini, gemini/gemini-2.5-pro",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const DEFAULT_SETTINGS: AISidebarSettings = {
|
|
|
|
|
providers: [
|
|
|
|
|
cloneProvider(PROVIDER_PRESETS[0].provider),
|
|
|
|
|
cloneProvider(PROVIDER_PRESETS[3].provider),
|
|
|
|
|
],
|
|
|
|
|
defaultProviderId: "codex",
|
|
|
|
|
defaultAccessMode: "confirm",
|
|
|
|
|
includeFolders: "",
|
2026-04-29 14:47:36 +00:00
|
|
|
excludeFolders: "node_modules, .git",
|
2026-04-29 11:20:13 +00:00
|
|
|
maxContextChars: 45000,
|
|
|
|
|
enableConversationMemory: true,
|
|
|
|
|
maxMemoryMessages: 30,
|
|
|
|
|
rememberedMessages: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default class AISidebarPlugin extends Plugin {
|
|
|
|
|
settings: AISidebarSettings;
|
|
|
|
|
|
|
|
|
|
async onload() {
|
|
|
|
|
await this.loadSettings();
|
|
|
|
|
|
|
|
|
|
this.registerView(
|
|
|
|
|
VIEW_TYPE_AI_SIDEBAR,
|
|
|
|
|
(leaf) => new AISidebarView(leaf, this),
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-29 14:47:36 +00:00
|
|
|
this.addRibbonIcon("sparkles", "Toggle AI sidebar", () => {
|
2026-04-29 11:20:13 +00:00
|
|
|
void this.toggleSidebar();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.addCommand({
|
2026-04-29 14:47:36 +00:00
|
|
|
id: "toggle-sidebar",
|
|
|
|
|
name: "Toggle sidebar",
|
2026-04-29 11:20:13 +00:00
|
|
|
callback: () => {
|
|
|
|
|
void this.toggleSidebar();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.addSettingTab(new AISidebarSettingTab(this.app, this));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async toggleSidebar() {
|
|
|
|
|
const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AI_SIDEBAR).first();
|
|
|
|
|
if (existingLeaf) {
|
|
|
|
|
this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const leaf = this.app.workspace.getRightLeaf(false);
|
|
|
|
|
if (!leaf) {
|
|
|
|
|
new Notice("Could not open the AI sidebar.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await leaf.setViewState({ type: VIEW_TYPE_AI_SIDEBAR, active: true });
|
2026-04-29 14:47:36 +00:00
|
|
|
await this.app.workspace.revealLeaf(leaf);
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadSettings() {
|
2026-04-29 14:47:36 +00:00
|
|
|
const loaded = await this.loadData() as Partial<AISidebarSettings> | null;
|
|
|
|
|
this.settings = { ...DEFAULT_SETTINGS, ...(loaded ?? {}) };
|
2026-04-29 11:20:13 +00:00
|
|
|
this.settings.providers = normalizeProviders(this.settings.providers);
|
|
|
|
|
this.settings.rememberedMessages = Array.isArray(this.settings.rememberedMessages)
|
|
|
|
|
? this.settings.rememberedMessages
|
|
|
|
|
: [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSettings() {
|
|
|
|
|
await this.saveData(this.settings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getProvider(id: string): AgentProvider | undefined {
|
|
|
|
|
return this.settings.providers.find((provider) => provider.id === id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async rememberMessages(messages: ChatMessage[]) {
|
|
|
|
|
if (!this.settings.enableConversationMemory) return;
|
|
|
|
|
this.settings.rememberedMessages = messages.slice(-this.settings.maxMemoryMessages);
|
|
|
|
|
await this.saveSettings();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async clearMemory() {
|
|
|
|
|
this.settings.rememberedMessages = [];
|
|
|
|
|
await this.saveSettings();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async upsertProviderFromPreset(preset: ProviderPreset) {
|
|
|
|
|
const provider = cloneProvider(preset.provider);
|
|
|
|
|
const existingIndex = this.settings.providers.findIndex((item) => item.id === provider.id);
|
|
|
|
|
if (existingIndex >= 0) {
|
|
|
|
|
this.settings.providers[existingIndex] = {
|
|
|
|
|
...provider,
|
|
|
|
|
apiKey: this.settings.providers[existingIndex].apiKey,
|
|
|
|
|
accessToken: this.settings.providers[existingIndex].accessToken,
|
|
|
|
|
refreshToken: this.settings.providers[existingIndex].refreshToken,
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
this.settings.providers.push(provider);
|
|
|
|
|
}
|
|
|
|
|
this.settings.defaultProviderId = provider.id;
|
|
|
|
|
await this.saveSettings();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class AISidebarView extends ItemView {
|
|
|
|
|
private plugin: AISidebarPlugin;
|
|
|
|
|
private messages: ChatMessage[] = [];
|
|
|
|
|
private providerId: string;
|
|
|
|
|
private accessMode: AccessMode;
|
|
|
|
|
private threadEl: HTMLElement;
|
|
|
|
|
private textareaEl: HTMLTextAreaElement;
|
|
|
|
|
private contextEl: HTMLElement;
|
|
|
|
|
private slashEl: HTMLElement;
|
2026-04-29 11:54:01 +00:00
|
|
|
private modelBadgeEl: HTMLElement;
|
2026-04-29 11:20:13 +00:00
|
|
|
private activeContextPath = "";
|
|
|
|
|
private refreshTimer: number | undefined;
|
|
|
|
|
private sendButton: ButtonComponent;
|
|
|
|
|
private isRunning = false;
|
|
|
|
|
|
|
|
|
|
constructor(leaf: WorkspaceLeaf, plugin: AISidebarPlugin) {
|
|
|
|
|
super(leaf);
|
|
|
|
|
this.plugin = plugin;
|
|
|
|
|
this.messages = plugin.settings.enableConversationMemory
|
|
|
|
|
? [...plugin.settings.rememberedMessages]
|
|
|
|
|
: [];
|
|
|
|
|
this.providerId = plugin.settings.defaultProviderId;
|
|
|
|
|
this.accessMode = plugin.settings.defaultAccessMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getViewType(): string {
|
|
|
|
|
return VIEW_TYPE_AI_SIDEBAR;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getDisplayText(): string {
|
2026-04-29 14:47:36 +00:00
|
|
|
return "AI sidebar";
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getIcon(): string {
|
|
|
|
|
return "sparkles";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async onOpen() {
|
|
|
|
|
this.render();
|
|
|
|
|
this.registerEvent(
|
|
|
|
|
this.app.workspace.on("active-leaf-change", () => {
|
|
|
|
|
this.scheduleContextRefresh();
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
this.registerEvent(
|
|
|
|
|
this.app.workspace.on("file-open", () => {
|
|
|
|
|
this.scheduleContextRefresh();
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
this.registerEvent(
|
|
|
|
|
this.app.vault.on("modify", (file) => {
|
|
|
|
|
if (file instanceof TFile && file.path === this.activeContextPath) {
|
|
|
|
|
this.scheduleContextRefresh();
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
await this.refreshContextPreview();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:47:36 +00:00
|
|
|
onClose(): Promise<void> {
|
2026-04-29 11:20:13 +00:00
|
|
|
this.contentEl.empty();
|
2026-04-29 14:47:36 +00:00
|
|
|
return Promise.resolve();
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private render() {
|
|
|
|
|
this.contentEl.empty();
|
|
|
|
|
this.contentEl.addClass("ai-sidebar");
|
|
|
|
|
|
|
|
|
|
const header = this.contentEl.createDiv("ai-sidebar__header");
|
2026-04-29 14:47:36 +00:00
|
|
|
header.createDiv({ cls: "ai-sidebar__title", text: "AI sidebar" });
|
2026-04-29 11:20:13 +00:00
|
|
|
header.createDiv({ cls: "ai-sidebar__subtitle", text: "Vault-aware agents" });
|
|
|
|
|
|
|
|
|
|
const controls = this.contentEl.createDiv("ai-sidebar__controls");
|
|
|
|
|
new DropdownComponent(controls)
|
|
|
|
|
.addOptions(this.providerOptions())
|
|
|
|
|
.setValue(this.providerId)
|
|
|
|
|
.onChange((value) => {
|
|
|
|
|
this.providerId = value;
|
2026-04-29 11:54:01 +00:00
|
|
|
this.updateModelBadge();
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new DropdownComponent(controls)
|
|
|
|
|
.addOptions({
|
|
|
|
|
"read-only": "Read only",
|
|
|
|
|
confirm: "Confirm actions",
|
|
|
|
|
"full-access": "Full access",
|
|
|
|
|
})
|
|
|
|
|
.setValue(this.accessMode)
|
|
|
|
|
.onChange((value: AccessMode) => {
|
|
|
|
|
this.accessMode = value;
|
|
|
|
|
void this.refreshContextPreview();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.contextEl = this.contentEl.createDiv("ai-sidebar__context");
|
|
|
|
|
|
|
|
|
|
this.threadEl = this.contentEl.createDiv("ai-sidebar__thread");
|
|
|
|
|
this.renderMessages();
|
|
|
|
|
|
|
|
|
|
const composer = this.contentEl.createDiv("ai-sidebar__composer");
|
|
|
|
|
this.textareaEl = composer.createEl("textarea", {
|
|
|
|
|
cls: "ai-sidebar__input",
|
|
|
|
|
attr: {
|
|
|
|
|
placeholder: "Ask about your vault, draft a note, or request an edit...",
|
|
|
|
|
rows: "4",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.textareaEl.addEventListener("keydown", (event) => {
|
|
|
|
|
if (event.key === "Escape" && !this.slashEl.hasClass("is-hidden")) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.hideSlashCommands();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-29 11:39:23 +00:00
|
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
2026-04-29 11:20:13 +00:00
|
|
|
event.preventDefault();
|
|
|
|
|
void this.sendPrompt();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
this.textareaEl.addEventListener("input", () => {
|
|
|
|
|
void this.updateSlashCommands();
|
2026-04-29 11:54:01 +00:00
|
|
|
this.updateModelBadge();
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.slashEl = composer.createDiv("ai-sidebar__slash is-hidden");
|
|
|
|
|
|
|
|
|
|
const actions = composer.createDiv("ai-sidebar__composer-actions");
|
|
|
|
|
new ButtonComponent(actions)
|
|
|
|
|
.setButtonText("Refresh context")
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
void this.refreshContextPreview();
|
|
|
|
|
});
|
|
|
|
|
new ButtonComponent(actions)
|
|
|
|
|
.setButtonText("Clear")
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
void this.clearConversation();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 11:54:01 +00:00
|
|
|
this.modelBadgeEl = actions.createDiv("ai-sidebar__model-badge");
|
|
|
|
|
this.updateModelBadge();
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
this.sendButton = new ButtonComponent(actions)
|
|
|
|
|
.setCta()
|
|
|
|
|
.setButtonText("Send")
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
void this.sendPrompt();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private providerOptions(): Record<string, string> {
|
|
|
|
|
return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {
|
|
|
|
|
options[provider.id] = provider.name || provider.id;
|
|
|
|
|
return options;
|
|
|
|
|
}, {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderMessages() {
|
|
|
|
|
this.threadEl.empty();
|
|
|
|
|
|
|
|
|
|
if (this.messages.length === 0) {
|
|
|
|
|
const empty = this.threadEl.createDiv("ai-sidebar__empty");
|
|
|
|
|
empty.createDiv({ text: "Ask a local agent to work with the notes in this vault." });
|
|
|
|
|
empty.createDiv({ text: "Use confirm mode when you want to review file edits first." });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const message of this.messages) {
|
|
|
|
|
const messageEl = this.threadEl.createDiv(`ai-sidebar__message ai-sidebar__message--${message.role}`);
|
|
|
|
|
messageEl.createDiv({ cls: "ai-sidebar__message-role", text: message.role });
|
|
|
|
|
messageEl.createEl("pre", { text: message.content });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:54:01 +00:00
|
|
|
if (this.isRunning) {
|
|
|
|
|
const thinkingEl = this.threadEl.createDiv("ai-sidebar__message ai-sidebar__message--thinking");
|
|
|
|
|
thinkingEl.createDiv({ cls: "ai-sidebar__message-role", text: "assistant" });
|
|
|
|
|
const row = thinkingEl.createDiv("ai-sidebar__typing");
|
|
|
|
|
row.createSpan({ text: "Thinking" });
|
|
|
|
|
const dots = row.createSpan("ai-sidebar__typing-dots");
|
|
|
|
|
dots.createSpan();
|
|
|
|
|
dots.createSpan();
|
|
|
|
|
dots.createSpan();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
this.threadEl.scrollTo({ top: this.threadEl.scrollHeight });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async refreshContextPreview(prompt = "") {
|
|
|
|
|
const context = await collectVaultContext(
|
|
|
|
|
this.plugin.app,
|
|
|
|
|
this.plugin.settings,
|
|
|
|
|
prompt,
|
|
|
|
|
this.extractSelectedSkillNames(prompt),
|
|
|
|
|
);
|
|
|
|
|
this.activeContextPath = context.activeFile?.path ?? "";
|
|
|
|
|
this.contextEl.empty();
|
|
|
|
|
this.contextEl.createDiv({
|
|
|
|
|
cls: "ai-sidebar__context-title",
|
|
|
|
|
text: `Context preview · ${this.accessModeLabel()}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const items = [
|
|
|
|
|
context.activeFile ? `Active: ${context.activeFile.path}` : "No active note",
|
|
|
|
|
context.selection ? "Selection included" : "No selection",
|
|
|
|
|
`${context.linkedFiles.length} linked`,
|
|
|
|
|
`${context.backlinkFiles.length} backlinks`,
|
|
|
|
|
`${context.relevantFiles.length} relevant`,
|
|
|
|
|
`${context.baseFiles.length} bases`,
|
|
|
|
|
`${context.selectedSkills.length} skills`,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const list = this.contextEl.createEl("ul");
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
list.createEl("li", { text: item });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private scheduleContextRefresh() {
|
|
|
|
|
window.clearTimeout(this.refreshTimer);
|
|
|
|
|
this.refreshTimer = window.setTimeout(() => {
|
|
|
|
|
void this.refreshContextPreview(this.textareaEl?.value ?? "");
|
|
|
|
|
}, 150);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private accessModeLabel(): string {
|
|
|
|
|
if (this.accessMode === "read-only") return "read only";
|
|
|
|
|
if (this.accessMode === "full-access") return "full access";
|
|
|
|
|
return "confirm actions";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async sendPrompt() {
|
|
|
|
|
const rawPrompt = this.textareaEl.value.trim();
|
|
|
|
|
if (!rawPrompt || this.isRunning) return;
|
|
|
|
|
|
|
|
|
|
const provider = this.plugin.getProvider(this.providerId);
|
|
|
|
|
if (!provider) {
|
2026-04-29 14:47:36 +00:00
|
|
|
new Notice("Choose a provider in AI sidebar settings.");
|
2026-04-29 11:20:13 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isProviderReady(provider)) {
|
2026-04-29 14:47:36 +00:00
|
|
|
new Notice("Connect or configure this provider in AI sidebar settings first.");
|
2026-04-29 11:20:13 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:54:01 +00:00
|
|
|
const parsed = this.parsePromptControls(rawPrompt, provider);
|
|
|
|
|
if (this.isSlashOnlyPrompt(rawPrompt, parsed)) {
|
|
|
|
|
await this.applySlashControls(provider, parsed);
|
|
|
|
|
this.textareaEl.value = "";
|
|
|
|
|
this.updateModelBadge();
|
|
|
|
|
await this.refreshContextPreview();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
this.isRunning = true;
|
|
|
|
|
this.sendButton.setButtonText("Running...");
|
2026-04-29 11:54:01 +00:00
|
|
|
this.sendButton.setDisabled(true);
|
2026-04-29 11:20:13 +00:00
|
|
|
this.textareaEl.value = "";
|
|
|
|
|
const effectiveAccessMode = parsed.accessMode ?? this.accessMode;
|
|
|
|
|
this.messages.push({ role: "user", content: rawPrompt });
|
|
|
|
|
this.renderMessages();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const context = await collectVaultContext(
|
|
|
|
|
this.plugin.app,
|
|
|
|
|
this.plugin.settings,
|
|
|
|
|
parsed.cleanPrompt,
|
|
|
|
|
parsed.selectedSkillNames,
|
|
|
|
|
);
|
|
|
|
|
await this.refreshContextPreview(rawPrompt);
|
|
|
|
|
const request: AgentRequest = {
|
|
|
|
|
prompt: parsed.cleanPrompt,
|
|
|
|
|
accessMode: effectiveAccessMode,
|
|
|
|
|
context,
|
|
|
|
|
instructions: buildAgentInstructions(effectiveAccessMode),
|
|
|
|
|
conversation: this.plugin.settings.enableConversationMemory ? this.messages.slice(0, -1) : [],
|
|
|
|
|
options: parsed.options,
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
const response = await runProvider(provider, request, this.app);
|
2026-04-29 11:20:13 +00:00
|
|
|
const actionResult = await this.handleActions(response);
|
|
|
|
|
const assistantContent = actionResult ? `${response}\n\n${actionResult}` : response;
|
|
|
|
|
this.messages.push({ role: "assistant", content: assistantContent.trim() || "No response." });
|
|
|
|
|
await this.plugin.rememberMessages(this.messages);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
this.messages.push({ role: "system", content: `Provider failed: ${message}` });
|
|
|
|
|
await this.plugin.rememberMessages(this.messages);
|
|
|
|
|
new Notice("AI provider failed. Check the sidebar for details.");
|
|
|
|
|
} finally {
|
|
|
|
|
this.isRunning = false;
|
|
|
|
|
this.sendButton.setButtonText("Send");
|
2026-04-29 11:54:01 +00:00
|
|
|
this.sendButton.setDisabled(false);
|
|
|
|
|
this.updateModelBadge();
|
2026-04-29 11:20:13 +00:00
|
|
|
this.renderMessages();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async clearConversation() {
|
|
|
|
|
this.messages = [];
|
|
|
|
|
await this.plugin.clearMemory();
|
|
|
|
|
this.renderMessages();
|
2026-04-29 14:47:36 +00:00
|
|
|
new Notice("AI sidebar memory cleared.");
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleActions(response: string): Promise<string> {
|
|
|
|
|
const actions = extractVaultActions(response);
|
|
|
|
|
if (actions.length === 0) return "";
|
|
|
|
|
|
|
|
|
|
if (this.accessMode === "read-only") {
|
|
|
|
|
return "Vault actions were proposed, but read-only mode blocked all writes.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.accessMode === "confirm") {
|
|
|
|
|
const approved = await confirmVaultActions(this.app, actions);
|
|
|
|
|
if (!approved) return "Vault actions were not applied.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const results: string[] = [];
|
|
|
|
|
for (const action of actions) {
|
|
|
|
|
try {
|
|
|
|
|
await applyVaultAction(this.app, action);
|
|
|
|
|
results.push(`Applied ${action.type}: ${action.path}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
results.push(`Failed ${action.type}: ${action.path} · ${message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results.join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async updateSlashCommands() {
|
|
|
|
|
const query = this.currentSlashQuery();
|
|
|
|
|
if (query === null) {
|
|
|
|
|
this.hideSlashCommands();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const provider = this.plugin.getProvider(this.providerId);
|
|
|
|
|
const commands = provider ? nativeSlashCommands(provider, query, this.accessMode) : [];
|
|
|
|
|
const skills = await listAgentSkills(this.plugin.app, query);
|
|
|
|
|
this.slashEl.empty();
|
|
|
|
|
|
|
|
|
|
if (commands.length === 0 && skills.length === 0) {
|
|
|
|
|
this.hideSlashCommands();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.slashEl.removeClass("is-hidden");
|
2026-04-29 11:56:07 +00:00
|
|
|
if (skills.length > 0) {
|
|
|
|
|
this.slashEl.createDiv({ cls: "ai-sidebar__slash-section", text: "Skills" });
|
|
|
|
|
}
|
|
|
|
|
for (const skill of skills.slice(0, 12)) {
|
2026-04-29 11:20:13 +00:00
|
|
|
const item = this.slashEl.createDiv("ai-sidebar__slash-item");
|
2026-04-29 11:56:07 +00:00
|
|
|
item.createDiv({ cls: "ai-sidebar__slash-name", text: `/${skill.name}` });
|
|
|
|
|
item.createDiv({ cls: "ai-sidebar__slash-path", text: skill.path });
|
2026-04-29 11:20:13 +00:00
|
|
|
item.addEventListener("mousedown", (event) => {
|
|
|
|
|
event.preventDefault();
|
2026-04-29 11:56:07 +00:00
|
|
|
this.insertSlashToken(`/${skill.name}`);
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
}
|
2026-04-29 11:56:07 +00:00
|
|
|
if (commands.length > 0) {
|
|
|
|
|
this.slashEl.createDiv({ cls: "ai-sidebar__slash-section", text: "Controls" });
|
|
|
|
|
}
|
|
|
|
|
for (const command of commands.slice(0, 8)) {
|
2026-04-29 11:20:13 +00:00
|
|
|
const item = this.slashEl.createDiv("ai-sidebar__slash-item");
|
2026-04-29 11:56:07 +00:00
|
|
|
item.createDiv({ cls: "ai-sidebar__slash-name", text: command.label });
|
|
|
|
|
item.createDiv({ cls: "ai-sidebar__slash-path", text: command.description });
|
2026-04-29 11:20:13 +00:00
|
|
|
item.addEventListener("mousedown", (event) => {
|
|
|
|
|
event.preventDefault();
|
2026-04-29 11:56:07 +00:00
|
|
|
this.insertSlashToken(command.insert);
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private hideSlashCommands() {
|
|
|
|
|
this.slashEl.empty();
|
|
|
|
|
this.slashEl.addClass("is-hidden");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private currentSlashQuery(): string | null {
|
|
|
|
|
const cursor = this.textareaEl.selectionStart;
|
|
|
|
|
const beforeCursor = this.textareaEl.value.slice(0, cursor);
|
2026-04-29 11:54:01 +00:00
|
|
|
const match = beforeCursor.match(/(?:^|\s)\/([A-Za-z0-9_.:-]*)$/);
|
2026-04-29 11:20:13 +00:00
|
|
|
return match ? match[1].toLowerCase() : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private insertSlashToken(token: string) {
|
|
|
|
|
const cursor = this.textareaEl.selectionStart;
|
|
|
|
|
const value = this.textareaEl.value;
|
|
|
|
|
const beforeCursor = value.slice(0, cursor);
|
|
|
|
|
const afterCursor = value.slice(cursor);
|
2026-04-29 11:54:01 +00:00
|
|
|
const replaced = beforeCursor.replace(/(?:^|\s)\/([A-Za-z0-9_.:-]*)$/, (match) => {
|
2026-04-29 11:20:13 +00:00
|
|
|
const prefix = match.startsWith(" ") ? " " : "";
|
|
|
|
|
return `${prefix}${token} `;
|
|
|
|
|
});
|
|
|
|
|
this.textareaEl.value = `${replaced}${afterCursor}`;
|
|
|
|
|
this.textareaEl.focus();
|
|
|
|
|
this.textareaEl.selectionStart = replaced.length;
|
|
|
|
|
this.textareaEl.selectionEnd = replaced.length;
|
|
|
|
|
this.hideSlashCommands();
|
|
|
|
|
this.scheduleContextRefresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extractSelectedSkillNames(prompt: string): string[] {
|
|
|
|
|
const native = new Set(["model", "reasoning", "access", "memory"]);
|
|
|
|
|
return Array.from(prompt.matchAll(/(?:^|\s)\/([A-Za-z0-9_.-]+)/g), (match) => match[1])
|
|
|
|
|
.filter((name) => !native.has(name.split(":")[0].toLowerCase()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parsePromptControls(prompt: string, provider: AgentProvider): ParsedPrompt {
|
|
|
|
|
const options: AgentRequestOptions = {
|
|
|
|
|
model: provider.model,
|
|
|
|
|
reasoningEffort: provider.reasoningEffort,
|
|
|
|
|
memoryEnabled: this.plugin.settings.enableConversationMemory,
|
|
|
|
|
};
|
|
|
|
|
let accessMode: AccessMode | undefined;
|
|
|
|
|
|
2026-04-29 14:47:36 +00:00
|
|
|
const cleanPrompt = prompt.replace(/(?:^|\s)\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match: string, key: string, value: string) => {
|
2026-04-29 11:20:13 +00:00
|
|
|
const normalizedKey = String(key).toLowerCase();
|
|
|
|
|
const normalizedValue = String(value).toLowerCase();
|
|
|
|
|
if (normalizedKey === "model") {
|
|
|
|
|
options.model = value;
|
|
|
|
|
} else if (normalizedKey === "reasoning" && isReasoningEffort(normalizedValue)) {
|
|
|
|
|
options.reasoningEffort = normalizedValue;
|
|
|
|
|
} else if (normalizedKey === "access" && isAccessMode(normalizedValue)) {
|
|
|
|
|
accessMode = normalizedValue;
|
|
|
|
|
} else if (normalizedKey === "memory") {
|
|
|
|
|
options.memoryEnabled = normalizedValue === "on";
|
|
|
|
|
}
|
|
|
|
|
return match.startsWith(" ") ? " " : "";
|
|
|
|
|
}).trim();
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-29 11:54:01 +00:00
|
|
|
cleanPrompt,
|
2026-04-29 11:20:13 +00:00
|
|
|
selectedSkillNames: this.extractSelectedSkillNames(prompt),
|
|
|
|
|
options,
|
|
|
|
|
accessMode,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-29 11:54:01 +00:00
|
|
|
|
|
|
|
|
private isSlashOnlyPrompt(prompt: string, parsed: ParsedPrompt): boolean {
|
|
|
|
|
return parsed.cleanPrompt.length === 0 && this.extractSelectedSkillNames(prompt).length === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async applySlashControls(provider: AgentProvider, parsed: ParsedPrompt) {
|
|
|
|
|
const changes: string[] = [];
|
|
|
|
|
if (parsed.options.model && parsed.options.model !== provider.model) {
|
|
|
|
|
provider.model = parsed.options.model;
|
|
|
|
|
changes.push(`model ${parsed.options.model}`);
|
|
|
|
|
}
|
|
|
|
|
if (parsed.options.reasoningEffort && parsed.options.reasoningEffort !== provider.reasoningEffort) {
|
|
|
|
|
provider.reasoningEffort = parsed.options.reasoningEffort;
|
|
|
|
|
changes.push(`reasoning ${parsed.options.reasoningEffort}`);
|
|
|
|
|
}
|
|
|
|
|
if (parsed.accessMode && parsed.accessMode !== this.accessMode) {
|
|
|
|
|
this.accessMode = parsed.accessMode;
|
|
|
|
|
this.plugin.settings.defaultAccessMode = parsed.accessMode;
|
|
|
|
|
changes.push(`access ${this.accessModeLabel()}`);
|
|
|
|
|
}
|
|
|
|
|
if (parsed.options.memoryEnabled !== this.plugin.settings.enableConversationMemory) {
|
|
|
|
|
this.plugin.settings.enableConversationMemory = parsed.options.memoryEnabled;
|
|
|
|
|
changes.push(`memory ${parsed.options.memoryEnabled ? "on" : "off"}`);
|
|
|
|
|
}
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
new Notice(changes.length > 0 ? `Updated ${changes.join(", ")}.` : "Slash command applied.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateModelBadge() {
|
|
|
|
|
if (!this.modelBadgeEl) return;
|
|
|
|
|
const provider = this.plugin.getProvider(this.providerId);
|
|
|
|
|
const override = this.textareaEl?.value.match(/(?:^|\s)\/model:([A-Za-z0-9_.:/-]+)/i)?.[1];
|
|
|
|
|
const model = override || provider?.model || parseProviderModels(provider ?? DEFAULT_SETTINGS.providers[0]).first() || "default";
|
|
|
|
|
this.modelBadgeEl.setText(model);
|
|
|
|
|
this.modelBadgeEl.toggleClass("is-override", Boolean(override));
|
|
|
|
|
this.modelBadgeEl.setAttr("aria-label", override ? `Model override: ${model}` : `Model: ${model}`);
|
|
|
|
|
}
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class AISidebarSettingTab extends PluginSettingTab {
|
|
|
|
|
plugin: AISidebarPlugin;
|
|
|
|
|
|
|
|
|
|
constructor(app: App, plugin: AISidebarPlugin) {
|
|
|
|
|
super(app, plugin);
|
|
|
|
|
this.plugin = plugin;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
display(): void {
|
|
|
|
|
const { containerEl } = this;
|
|
|
|
|
containerEl.empty();
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Default provider")
|
|
|
|
|
.setDesc("The AI provider used when the sidebar opens.")
|
|
|
|
|
.addDropdown((dropdown) => {
|
|
|
|
|
dropdown.addOptions(this.providerOptions());
|
|
|
|
|
dropdown.setValue(this.plugin.settings.defaultProviderId);
|
|
|
|
|
dropdown.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.defaultProviderId = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Default vault access")
|
|
|
|
|
.setDesc("How much permission the agent has when it proposes changes.")
|
|
|
|
|
.addDropdown((dropdown) => {
|
|
|
|
|
dropdown
|
|
|
|
|
.addOption("read-only", "Read only")
|
|
|
|
|
.addOption("confirm", "Confirm actions")
|
|
|
|
|
.addOption("full-access", "Full access")
|
|
|
|
|
.setValue(this.plugin.settings.defaultAccessMode)
|
|
|
|
|
.onChange(async (value: AccessMode) => {
|
|
|
|
|
this.plugin.settings.defaultAccessMode = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 12:07:37 +00:00
|
|
|
new Setting(containerEl)
|
2026-04-29 14:47:36 +00:00
|
|
|
.setName("Sidebar shortcut")
|
|
|
|
|
.setDesc("Set a shortcut from Obsidian's hotkeys settings by searching for toggle sidebar.");
|
2026-04-29 12:07:37 +00:00
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Include folders")
|
|
|
|
|
.setDesc("Comma-separated folder prefixes to include. Leave blank for the whole vault.")
|
|
|
|
|
.addText((text) => {
|
|
|
|
|
text
|
2026-04-29 14:47:36 +00:00
|
|
|
.setPlaceholder("Projects, daily notes")
|
2026-04-29 11:20:13 +00:00
|
|
|
.setValue(this.plugin.settings.includeFolders)
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.includeFolders = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Exclude folders")
|
|
|
|
|
.setDesc("Comma-separated folder prefixes that should never be sent to an agent.")
|
|
|
|
|
.addText((text) => {
|
|
|
|
|
text
|
|
|
|
|
.setValue(this.plugin.settings.excludeFolders)
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.excludeFolders = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Max context characters")
|
|
|
|
|
.setDesc("Upper bound for note text sent to the agent.")
|
|
|
|
|
.addText((text) => {
|
|
|
|
|
text
|
|
|
|
|
.setValue(String(this.plugin.settings.maxContextChars))
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
if (Number.isFinite(parsed) && parsed > 1000) {
|
|
|
|
|
this.plugin.settings.maxContextChars = parsed;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Conversation memory")
|
|
|
|
|
.setDesc("Remember previous sidebar messages after closing and reopening Obsidian.")
|
|
|
|
|
.addToggle((toggle) => {
|
|
|
|
|
toggle
|
|
|
|
|
.setValue(this.plugin.settings.enableConversationMemory)
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
this.plugin.settings.enableConversationMemory = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Memory length")
|
|
|
|
|
.setDesc("Maximum number of recent sidebar messages to remember.")
|
|
|
|
|
.addText((text) => {
|
|
|
|
|
text
|
|
|
|
|
.setValue(String(this.plugin.settings.maxMemoryMessages))
|
|
|
|
|
.onChange(async (value) => {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
if (Number.isFinite(parsed) && parsed >= 2) {
|
|
|
|
|
this.plugin.settings.maxMemoryMessages = parsed;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.addButton((button) => {
|
|
|
|
|
button.setButtonText("Clear memory").onClick(async () => {
|
|
|
|
|
await this.plugin.clearMemory();
|
2026-04-29 14:47:36 +00:00
|
|
|
new Notice("AI sidebar memory cleared.");
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 14:47:36 +00:00
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Connect a provider")
|
|
|
|
|
.setHeading();
|
2026-04-29 11:20:13 +00:00
|
|
|
this.renderProviderPresets(containerEl);
|
|
|
|
|
|
2026-04-29 14:47:36 +00:00
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Connected providers")
|
|
|
|
|
.setHeading();
|
2026-04-29 11:20:13 +00:00
|
|
|
for (const provider of this.plugin.settings.providers) {
|
|
|
|
|
this.renderProviderSetting(containerEl, provider);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
new Setting(containerEl)
|
|
|
|
|
.setName("Advanced provider")
|
2026-04-29 14:47:36 +00:00
|
|
|
.setDesc("Create a custom API, OAUTH, or local CLI provider.")
|
2026-04-29 11:20:13 +00:00
|
|
|
.addButton((button) => {
|
|
|
|
|
button.setButtonText("Add custom").onClick(async () => {
|
|
|
|
|
const id = `provider-${Date.now()}`;
|
|
|
|
|
this.plugin.settings.providers.push({ id, name: "New provider", authType: "oauth", command: "" });
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
this.display();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderProviderPresets(containerEl: HTMLElement) {
|
|
|
|
|
const grid = containerEl.createDiv("ai-sidebar-provider-grid");
|
|
|
|
|
for (const preset of PROVIDER_PRESETS) {
|
|
|
|
|
const provider = this.plugin.getProvider(preset.provider.id);
|
|
|
|
|
const card = grid.createDiv("ai-sidebar-provider-card");
|
|
|
|
|
card.createDiv({ cls: "ai-sidebar-provider-card__name", text: preset.name });
|
|
|
|
|
card.createDiv({ cls: "ai-sidebar-provider-card__desc", text: preset.description });
|
|
|
|
|
const status = provider ? providerConnectionLabel(provider) : "Not added";
|
|
|
|
|
card.createDiv({ cls: "ai-sidebar-provider-card__status", text: status });
|
|
|
|
|
|
|
|
|
|
const actions = card.createDiv("ai-sidebar-provider-card__actions");
|
|
|
|
|
new ButtonComponent(actions)
|
|
|
|
|
.setButtonText(provider ? "Use" : "Add")
|
|
|
|
|
.onClick(async () => {
|
|
|
|
|
await this.plugin.upsertProviderFromPreset(preset);
|
|
|
|
|
this.display();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new ButtonComponent(actions)
|
|
|
|
|
.setCta()
|
|
|
|
|
.setButtonText(providerConnectionActionText(provider ?? preset.provider))
|
|
|
|
|
.onClick(async () => {
|
|
|
|
|
await this.plugin.upsertProviderFromPreset(preset);
|
|
|
|
|
const connectedProvider = this.plugin.getProvider(preset.provider.id) ?? preset.provider;
|
2026-04-29 11:39:23 +00:00
|
|
|
if (await startProviderSignIn(connectedProvider)) {
|
|
|
|
|
connectedProvider.connectedAt = Date.now();
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
}
|
2026-04-29 11:20:13 +00:00
|
|
|
this.display();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private providerOptions(): Record<string, string> {
|
|
|
|
|
return this.plugin.settings.providers.reduce<Record<string, string>>((options, provider) => {
|
|
|
|
|
options[provider.id] = provider.name || provider.id;
|
|
|
|
|
return options;
|
|
|
|
|
}, {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderProviderSetting(containerEl: HTMLElement, provider: AgentProvider) {
|
|
|
|
|
const wrapper = containerEl.createDiv("ai-sidebar-settings-provider");
|
2026-04-29 11:39:23 +00:00
|
|
|
const header = wrapper.createDiv("ai-sidebar-settings-provider__header");
|
|
|
|
|
const title = header.createDiv("ai-sidebar-settings-provider__title");
|
|
|
|
|
title.createDiv({ cls: "ai-sidebar-settings-provider__name", text: provider.name || provider.id });
|
|
|
|
|
title.createDiv({ cls: "ai-sidebar-settings-provider__desc", text: this.providerDescription(provider) });
|
|
|
|
|
header.createSpan({
|
|
|
|
|
cls: `ai-sidebar-settings-provider__badge ${isProviderConnected(provider) ? "is-connected" : ""}`,
|
|
|
|
|
text: providerConnectionLabel(provider),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const actions = wrapper.createDiv("ai-sidebar-settings-provider__actions");
|
|
|
|
|
new ButtonComponent(actions)
|
|
|
|
|
.setButtonText(this.plugin.settings.defaultProviderId === provider.id ? "Default" : "Use")
|
|
|
|
|
.setDisabled(this.plugin.settings.defaultProviderId === provider.id)
|
|
|
|
|
.onClick(async () => {
|
|
|
|
|
this.plugin.settings.defaultProviderId = provider.id;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
this.display();
|
|
|
|
|
});
|
|
|
|
|
this.configureConnectButton(new ButtonComponent(actions), provider);
|
|
|
|
|
new ButtonComponent(actions)
|
|
|
|
|
.setIcon("trash")
|
|
|
|
|
.setTooltip("Remove provider")
|
|
|
|
|
.onClick(async () => {
|
|
|
|
|
this.plugin.settings.providers = this.plugin.settings.providers.filter((item) => item !== provider);
|
|
|
|
|
if (this.plugin.settings.defaultProviderId === provider.id) {
|
|
|
|
|
this.plugin.settings.defaultProviderId = this.plugin.settings.providers.first()?.id ?? "";
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
2026-04-29 11:39:23 +00:00
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
this.display();
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
const fields = wrapper.createDiv("ai-sidebar-settings-provider__fields");
|
|
|
|
|
this.renderTextField(fields, "Name", provider.name, async (value) => {
|
|
|
|
|
provider.name = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
this.display();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (provider.authType === "cli") {
|
|
|
|
|
this.renderTextField(fields, "Command", provider.command, async (value) => {
|
|
|
|
|
provider.command = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
if (provider.signInCommand) {
|
|
|
|
|
this.renderTextField(fields, "Sign-in command", provider.signInCommand, async (value) => {
|
|
|
|
|
provider.signInCommand = value;
|
|
|
|
|
provider.connectedAt = undefined;
|
|
|
|
|
await this.plugin.saveSettings();
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
2026-04-29 11:39:23 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (provider.authType === "api-key") {
|
|
|
|
|
this.renderTextField(fields, "API key", provider.apiKey ?? "", async (value) => {
|
|
|
|
|
provider.apiKey = value;
|
|
|
|
|
provider.connectedAt = value ? Date.now() : undefined;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
this.display();
|
|
|
|
|
}, true);
|
|
|
|
|
this.renderTextField(fields, "Endpoint", provider.apiBaseUrl ?? "", async (value) => {
|
|
|
|
|
provider.apiBaseUrl = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
this.renderTextField(fields, "Default model", provider.model ?? "", async (value) => {
|
|
|
|
|
provider.model = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
this.renderTextField(fields, "Slash models", provider.models ?? "", async (value) => {
|
|
|
|
|
provider.models = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (provider.authType === "oauth") {
|
2026-04-29 11:39:23 +00:00
|
|
|
this.renderTextField(fields, "Authorization URL", provider.authorizationUrl ?? "", async (value) => {
|
|
|
|
|
provider.authorizationUrl = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
this.renderTextField(fields, "Client ID", provider.clientId ?? "", async (value) => {
|
|
|
|
|
provider.clientId = value;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
});
|
|
|
|
|
this.renderTextField(fields, "Access token", provider.accessToken ?? "", async (value) => {
|
|
|
|
|
provider.accessToken = value;
|
|
|
|
|
provider.connectedAt = value ? Date.now() : undefined;
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
this.display();
|
|
|
|
|
}, true);
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
private renderTextField(
|
|
|
|
|
containerEl: HTMLElement,
|
|
|
|
|
label: string,
|
|
|
|
|
value: string,
|
|
|
|
|
onChange: (value: string) => Promise<void>,
|
|
|
|
|
secret = false,
|
|
|
|
|
) {
|
|
|
|
|
const field = containerEl.createDiv("ai-sidebar-settings-field");
|
|
|
|
|
field.createDiv({ cls: "ai-sidebar-settings-field__label", text: label });
|
|
|
|
|
const input = field.createEl("input", {
|
|
|
|
|
cls: "ai-sidebar-settings-field__input",
|
|
|
|
|
attr: { type: secret ? "password" : "text" },
|
|
|
|
|
});
|
|
|
|
|
input.value = value;
|
|
|
|
|
input.addEventListener("change", () => {
|
|
|
|
|
void onChange(input.value);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
private providerDescription(provider: AgentProvider): string {
|
|
|
|
|
if (provider.authType === "api-key") {
|
|
|
|
|
return provider.apiKey ? "Connected with API key." : "Add an API key to connect.";
|
|
|
|
|
}
|
|
|
|
|
if (provider.authType === "oauth") {
|
|
|
|
|
return provider.accessToken ? "Connected with OAuth." : "Connect with OAuth when the provider exposes a desktop OAuth app.";
|
|
|
|
|
}
|
|
|
|
|
return provider.command ? `Runs local command: ${provider.command}` : "Add the local CLI command.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private configureConnectButton(button: ButtonComponent, provider: AgentProvider) {
|
|
|
|
|
button
|
|
|
|
|
.setButtonText(providerConnectionActionText(provider))
|
2026-04-29 11:39:23 +00:00
|
|
|
.onClick(async () => {
|
|
|
|
|
if (await startProviderSignIn(provider)) {
|
|
|
|
|
provider.connectedAt = Date.now();
|
|
|
|
|
await this.plugin.saveSettings();
|
|
|
|
|
this.display();
|
|
|
|
|
}
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class ConfirmActionsModal extends Modal {
|
|
|
|
|
private actions: VaultAction[];
|
|
|
|
|
private resolve: (approved: boolean) => void;
|
|
|
|
|
|
|
|
|
|
constructor(app: App, actions: VaultAction[], resolve: (approved: boolean) => void) {
|
|
|
|
|
super(app);
|
|
|
|
|
this.actions = actions;
|
|
|
|
|
this.resolve = resolve;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onOpen() {
|
|
|
|
|
const { contentEl } = this;
|
|
|
|
|
contentEl.empty();
|
2026-04-29 14:47:36 +00:00
|
|
|
new Setting(contentEl)
|
|
|
|
|
.setName("Apply AI vault actions?")
|
|
|
|
|
.setHeading();
|
2026-04-29 11:20:13 +00:00
|
|
|
contentEl.createEl("p", { text: "Review these proposed file changes before they are applied." });
|
|
|
|
|
|
|
|
|
|
for (const action of this.actions) {
|
|
|
|
|
const actionEl = contentEl.createDiv("ai-sidebar-action-preview");
|
|
|
|
|
actionEl.createEl("strong", { text: `${action.type}: ${action.path}` });
|
|
|
|
|
if (action.type === "rename") {
|
|
|
|
|
actionEl.createDiv({ text: `New path: ${action.newPath}` });
|
|
|
|
|
}
|
|
|
|
|
if ("content" in action) {
|
|
|
|
|
actionEl.createEl("pre", { text: action.content.slice(0, 1200) });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
new Setting(contentEl)
|
|
|
|
|
.addButton((button) => {
|
|
|
|
|
button.setButtonText("Cancel").onClick(() => {
|
|
|
|
|
this.resolve(false);
|
|
|
|
|
this.close();
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.addButton((button) => {
|
|
|
|
|
button.setCta().setButtonText("Apply").onClick(() => {
|
|
|
|
|
this.resolve(true);
|
|
|
|
|
this.close();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onClose() {
|
|
|
|
|
this.contentEl.empty();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function collectVaultContext(
|
|
|
|
|
app: App,
|
|
|
|
|
settings: AISidebarSettings,
|
|
|
|
|
prompt: string,
|
|
|
|
|
selectedSkillNames: string[] = [],
|
|
|
|
|
): Promise<VaultContext> {
|
|
|
|
|
const activeView = app.workspace.getActiveViewOfType(MarkdownView);
|
|
|
|
|
const activeFile = activeView?.file ?? app.workspace.getActiveFile();
|
|
|
|
|
const selection = activeView?.editor.getSelection() || undefined;
|
|
|
|
|
const budget = new ContextBudget(settings.maxContextChars);
|
|
|
|
|
|
|
|
|
|
const context: VaultContext = {
|
|
|
|
|
selection,
|
|
|
|
|
linkedFiles: [],
|
|
|
|
|
backlinkFiles: [],
|
|
|
|
|
relevantFiles: [],
|
|
|
|
|
baseFiles: [],
|
|
|
|
|
selectedSkills: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const activeContext = activeFile ? await readContextFile(app, activeFile, "active note", budget) : undefined;
|
|
|
|
|
if (activeContext) context.activeFile = activeContext;
|
|
|
|
|
|
|
|
|
|
const linked = activeFile ? getLinkedFiles(app, activeFile) : [];
|
|
|
|
|
for (const file of linked) {
|
|
|
|
|
const item = await readContextFile(app, file, "linked note", budget);
|
|
|
|
|
if (item) context.linkedFiles.push(item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const backlinks = activeFile ? getBacklinkFiles(app, activeFile) : [];
|
|
|
|
|
for (const file of backlinks) {
|
|
|
|
|
const item = await readContextFile(app, file, "backlink", budget);
|
|
|
|
|
if (item) context.backlinkFiles.push(item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const baseFiles = app.vault
|
|
|
|
|
.getFiles()
|
2026-04-29 14:47:36 +00:00
|
|
|
.filter((file) => file.extension === "base" && shouldIncludeFile(app, file, settings))
|
2026-04-29 11:20:13 +00:00
|
|
|
.slice(0, 5);
|
|
|
|
|
for (const file of baseFiles) {
|
|
|
|
|
const item = await readContextFile(app, file, "base file", budget);
|
|
|
|
|
if (item) context.baseFiles.push(item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const relatedFiles = await findRelevantFiles(app, settings, prompt, activeFile, budget.remaining());
|
|
|
|
|
for (const file of relatedFiles) {
|
|
|
|
|
const item = await readContextFile(app, file, "relevant match", budget);
|
|
|
|
|
if (item) context.relevantFiles.push(item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.selectedSkills = await readSelectedSkills(app, selectedSkillNames, budget);
|
|
|
|
|
|
|
|
|
|
return context;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readContextFile(
|
|
|
|
|
app: App,
|
|
|
|
|
file: TFile,
|
|
|
|
|
reason: string,
|
|
|
|
|
budget: ContextBudget,
|
|
|
|
|
): Promise<VaultContextFile | undefined> {
|
|
|
|
|
if (!budget.hasRoom()) return undefined;
|
|
|
|
|
const content = await app.vault.cachedRead(file);
|
|
|
|
|
const clipped = budget.take(content);
|
|
|
|
|
return { path: file.path, content: clipped, reason };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getLinkedFiles(app: App, activeFile: TFile): TFile[] {
|
|
|
|
|
const cache = app.metadataCache.getFileCache(activeFile);
|
|
|
|
|
const links = cache?.links ?? [];
|
|
|
|
|
const files: TFile[] = [];
|
|
|
|
|
|
|
|
|
|
for (const link of links) {
|
|
|
|
|
const linkedFile = app.metadataCache.getFirstLinkpathDest(link.link, activeFile.path);
|
|
|
|
|
if (linkedFile) files.push(linkedFile);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return uniqueFiles(files).slice(0, 8);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getBacklinkFiles(app: App, activeFile: TFile): TFile[] {
|
|
|
|
|
const resolvedLinks = app.metadataCache.resolvedLinks;
|
|
|
|
|
const files: TFile[] = [];
|
|
|
|
|
|
|
|
|
|
for (const [sourcePath, targets] of Object.entries(resolvedLinks)) {
|
|
|
|
|
if (targets[activeFile.path]) {
|
|
|
|
|
const file = app.vault.getAbstractFileByPath(sourcePath);
|
|
|
|
|
if (file instanceof TFile) files.push(file);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return uniqueFiles(files).slice(0, 8);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function findRelevantFiles(
|
|
|
|
|
app: App,
|
|
|
|
|
settings: AISidebarSettings,
|
|
|
|
|
prompt: string,
|
|
|
|
|
activeFile: TFile | null,
|
|
|
|
|
maxChars: number,
|
|
|
|
|
): Promise<TFile[]> {
|
|
|
|
|
if (!prompt.trim() || maxChars <= 0) return [];
|
|
|
|
|
const terms = prompt
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.split(/[^a-z0-9/_-]+/i)
|
|
|
|
|
.filter((term) => term.length > 2)
|
|
|
|
|
.slice(0, 16);
|
|
|
|
|
if (terms.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
const scored: Array<{ file: TFile; score: number }> = [];
|
|
|
|
|
const files = app.vault
|
|
|
|
|
.getFiles()
|
2026-04-29 14:47:36 +00:00
|
|
|
.filter((file) => file !== activeFile && shouldIncludeFile(app, file, settings) && isReadableContextFile(file));
|
2026-04-29 11:20:13 +00:00
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
const content = (await app.vault.cachedRead(file)).toLowerCase();
|
|
|
|
|
const path = file.path.toLowerCase();
|
|
|
|
|
let score = 0;
|
|
|
|
|
for (const term of terms) {
|
|
|
|
|
if (path.includes(term)) score += 4;
|
|
|
|
|
const matches = content.match(new RegExp(escapeRegExp(term), "g"));
|
|
|
|
|
score += matches ? Math.min(matches.length, 8) : 0;
|
|
|
|
|
}
|
|
|
|
|
if (score > 0) scored.push({ file, score });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return scored
|
|
|
|
|
.sort((a, b) => b.score - a.score)
|
|
|
|
|
.slice(0, 10)
|
|
|
|
|
.map((item) => item.file);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:47:36 +00:00
|
|
|
function shouldIncludeFile(app: App, file: TFile, settings: AISidebarSettings): boolean {
|
2026-04-29 11:20:13 +00:00
|
|
|
const includes = parseFolderList(settings.includeFolders);
|
2026-04-29 14:47:36 +00:00
|
|
|
const excludes = [app.vault.configDir, ...parseFolderList(settings.excludeFolders)];
|
2026-04-29 11:20:13 +00:00
|
|
|
if (includes.length > 0 && !includes.some((folder) => file.path.startsWith(folder))) return false;
|
|
|
|
|
if (excludes.some((folder) => file.path.startsWith(folder))) return false;
|
|
|
|
|
return isReadableContextFile(file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isReadableContextFile(file: TFile): boolean {
|
|
|
|
|
return ["md", "canvas", "base", "json", "txt"].includes(file.extension);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nativeSlashCommands(
|
|
|
|
|
provider: AgentProvider,
|
|
|
|
|
query: string,
|
|
|
|
|
currentAccessMode: AccessMode,
|
|
|
|
|
): Array<{ label: string; description: string; insert: string }> {
|
|
|
|
|
const commands: Array<{ label: string; description: string; insert: string }> = [];
|
|
|
|
|
const modelChoices = parseProviderModels(provider);
|
|
|
|
|
|
|
|
|
|
for (const model of modelChoices) {
|
|
|
|
|
commands.push({
|
|
|
|
|
label: `/model:${model}`,
|
|
|
|
|
description: `Use ${model} for this request`,
|
|
|
|
|
insert: `/model:${model}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (provider.authType === "api-key" || provider.id.includes("codex") || provider.id.includes("openai")) {
|
|
|
|
|
for (const effort of ["low", "medium", "high"] as ReasoningEffort[]) {
|
|
|
|
|
commands.push({
|
|
|
|
|
label: `/reasoning:${effort}`,
|
|
|
|
|
description: `Set reasoning effort to ${effort}`,
|
|
|
|
|
insert: `/reasoning:${effort}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const mode of ["read-only", "confirm", "full-access"] as AccessMode[]) {
|
|
|
|
|
commands.push({
|
|
|
|
|
label: `/access:${mode}`,
|
|
|
|
|
description: mode === currentAccessMode ? "Current vault access mode" : `Use ${mode} for this request`,
|
|
|
|
|
insert: `/access:${mode}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
commands.push(
|
|
|
|
|
{ label: "/memory:on", description: "Include remembered chat history for this request", insert: "/memory:on" },
|
|
|
|
|
{ label: "/memory:off", description: "Do not include remembered chat history for this request", insert: "/memory:off" },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const normalizedQuery = query.toLowerCase();
|
|
|
|
|
return commands.filter((command) => command.label.slice(1).toLowerCase().includes(normalizedQuery));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseProviderModels(provider: AgentProvider): string[] {
|
|
|
|
|
const configured = provider.models
|
|
|
|
|
?.split(",")
|
|
|
|
|
.map((model) => model.trim())
|
|
|
|
|
.filter(Boolean) ?? [];
|
|
|
|
|
const models = configured.length > 0 ? configured : [provider.model].filter((model): model is string => Boolean(model));
|
|
|
|
|
return Array.from(new Set(models)).slice(0, 12);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:54:01 +00:00
|
|
|
async function listAgentSkills(app: App, query = ""): Promise<SkillReference[]> {
|
2026-04-29 11:20:13 +00:00
|
|
|
const normalizedQuery = query.toLowerCase();
|
2026-04-29 11:54:01 +00:00
|
|
|
const vaultSkills = app.vault
|
2026-04-29 11:20:13 +00:00
|
|
|
.getFiles()
|
|
|
|
|
.filter((file) => isSkillFile(file))
|
|
|
|
|
.map((file) => ({
|
|
|
|
|
name: skillNameFromPath(file.path),
|
|
|
|
|
path: file.path,
|
2026-04-29 11:54:01 +00:00
|
|
|
source: "vault" as const,
|
|
|
|
|
}));
|
|
|
|
|
const adapterSkills = await listAgentSkillsFromAdapter(app);
|
|
|
|
|
const globalSkills = await listGlobalAgentSkills();
|
|
|
|
|
const skills = [...vaultSkills, ...adapterSkills, ...globalSkills]
|
2026-04-29 11:20:13 +00:00
|
|
|
.filter((skill) => !normalizedQuery || skill.name.toLowerCase().includes(normalizedQuery))
|
|
|
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
return skills.filter((skill) => {
|
2026-04-29 11:54:01 +00:00
|
|
|
const key = `${skill.source}:${skill.name.toLowerCase()}`;
|
2026-04-29 11:20:13 +00:00
|
|
|
if (seen.has(key)) return false;
|
|
|
|
|
seen.add(key);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:54:01 +00:00
|
|
|
async function listAgentSkillsFromAdapter(app: App): Promise<SkillReference[]> {
|
|
|
|
|
const roots = [".agent/skills", ".agents/skills", "agent/skills", "agents/skills"];
|
|
|
|
|
const files: string[] = [];
|
|
|
|
|
for (const root of roots) {
|
|
|
|
|
files.push(...await listAdapterFiles(app, root));
|
|
|
|
|
}
|
|
|
|
|
return files
|
|
|
|
|
.filter((path) => isSkillPath(path))
|
|
|
|
|
.map((path) => ({
|
|
|
|
|
name: skillNameFromPath(path),
|
|
|
|
|
path,
|
|
|
|
|
source: "vault" as const,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listGlobalAgentSkills(): Promise<SkillReference[]> {
|
|
|
|
|
const roots = [
|
2026-04-29 12:07:37 +00:00
|
|
|
path.join(homedir(), ".agent", "skills", "skills"),
|
|
|
|
|
path.join(homedir(), ".agents", "skills", "skills"),
|
2026-04-29 11:54:01 +00:00
|
|
|
];
|
|
|
|
|
const files: string[] = [];
|
|
|
|
|
for (const root of roots) {
|
|
|
|
|
files.push(...await listFilesystemFiles(root));
|
|
|
|
|
}
|
|
|
|
|
return files
|
2026-04-29 12:07:37 +00:00
|
|
|
.filter((filePath) => isGlobalSkillPath(filePath))
|
2026-04-29 11:54:01 +00:00
|
|
|
.map((filePath) => ({
|
|
|
|
|
name: skillNameFromPath(filePath),
|
|
|
|
|
path: filePath,
|
|
|
|
|
source: "global" as const,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listFilesystemFiles(folder: string): Promise<string[]> {
|
|
|
|
|
try {
|
|
|
|
|
const entries = await fs.readdir(folder, { withFileTypes: true });
|
|
|
|
|
const results = await Promise.all(entries.map(async (entry) => {
|
|
|
|
|
const childPath = path.join(folder, entry.name);
|
|
|
|
|
if (entry.isDirectory()) return listFilesystemFiles(childPath);
|
|
|
|
|
if (entry.isFile()) return [childPath];
|
|
|
|
|
return [];
|
|
|
|
|
}));
|
|
|
|
|
return results.flat();
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listAdapterFiles(app: App, folder: string): Promise<string[]> {
|
|
|
|
|
try {
|
|
|
|
|
const listed = await app.vault.adapter.list(folder);
|
|
|
|
|
const childFiles = listed.files;
|
|
|
|
|
const nested = await Promise.all(listed.folders.map((child) => listAdapterFiles(app, child)));
|
|
|
|
|
return [...childFiles, ...nested.flat()];
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
async function readSelectedSkills(
|
|
|
|
|
app: App,
|
|
|
|
|
selectedSkillNames: string[],
|
|
|
|
|
budget: ContextBudget,
|
|
|
|
|
): Promise<AgentSkill[]> {
|
|
|
|
|
const names = new Set(selectedSkillNames.map((name) => name.toLowerCase()));
|
|
|
|
|
if (names.size === 0) return [];
|
|
|
|
|
|
|
|
|
|
const skills: AgentSkill[] = [];
|
2026-04-29 11:54:01 +00:00
|
|
|
const skillFiles = [...await listAgentSkillsFromAdapter(app), ...await listGlobalAgentSkills()];
|
|
|
|
|
const vaultFiles = app.vault
|
|
|
|
|
.getFiles()
|
|
|
|
|
.filter((candidate) => isSkillFile(candidate))
|
|
|
|
|
.map((file) => ({ name: skillNameFromPath(file.path), path: file.path, source: "vault" as const }));
|
|
|
|
|
for (const skillFile of [...vaultFiles, ...skillFiles]) {
|
|
|
|
|
const name = skillFile.name;
|
2026-04-29 11:20:13 +00:00
|
|
|
if (!names.has(name.toLowerCase()) || !budget.hasRoom()) continue;
|
2026-04-29 11:54:01 +00:00
|
|
|
const content = await readSkillContent(app, skillFile);
|
2026-04-29 11:20:13 +00:00
|
|
|
skills.push({
|
|
|
|
|
name,
|
2026-04-29 11:54:01 +00:00
|
|
|
path: skillFile.path,
|
2026-04-29 11:20:13 +00:00
|
|
|
content: budget.take(content),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return skills;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:54:01 +00:00
|
|
|
async function readSkillContent(app: App, skill: SkillReference): Promise<string> {
|
|
|
|
|
if (skill.source === "global") return fs.readFile(skill.path, "utf8");
|
|
|
|
|
const file = app.vault.getAbstractFileByPath(skill.path);
|
|
|
|
|
if (file instanceof TFile) return app.vault.cachedRead(file);
|
|
|
|
|
return app.vault.adapter.read(skill.path);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
function isSkillFile(file: TFile): boolean {
|
2026-04-29 11:54:01 +00:00
|
|
|
return isSkillPath(file.path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSkillPath(path: string): boolean {
|
2026-04-29 12:07:37 +00:00
|
|
|
if (!hasSupportedSkillExtension(path)) return false;
|
2026-04-29 11:54:01 +00:00
|
|
|
const normalized = path.replace(/^\/+/, "");
|
|
|
|
|
return normalized.startsWith(".agent/skills/")
|
|
|
|
|
|| normalized.startsWith(".agents/skills/")
|
|
|
|
|
|| normalized.startsWith("agent/skills/")
|
|
|
|
|
|| normalized.startsWith("agents/skills/");
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:07:37 +00:00
|
|
|
function hasSupportedSkillExtension(path: string): boolean {
|
|
|
|
|
const extension = path.split(".").last()?.toLowerCase() ?? "";
|
|
|
|
|
return ["md", "txt", "json", "yaml", "yml", "prompt"].includes(extension);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isGlobalSkillPath(filePath: string): boolean {
|
|
|
|
|
return path.basename(filePath).toLowerCase() === "skill.md";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
function skillNameFromPath(path: string): string {
|
|
|
|
|
const parts = path.split("/");
|
|
|
|
|
const fileName = parts.last() ?? path;
|
|
|
|
|
if (fileName.toLowerCase() === "skill.md" && parts.length > 1) {
|
|
|
|
|
return parts[parts.length - 2];
|
|
|
|
|
}
|
|
|
|
|
return fileName.replace(/\.[^.]+$/, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseFolderList(value: string): string[] {
|
|
|
|
|
return value
|
|
|
|
|
.split(",")
|
|
|
|
|
.map((folder) => folder.trim().replace(/^\/+/, ""))
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class ContextBudget {
|
|
|
|
|
private remainingChars: number;
|
|
|
|
|
|
|
|
|
|
constructor(maxChars: number) {
|
|
|
|
|
this.remainingChars = maxChars;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hasRoom(): boolean {
|
|
|
|
|
return this.remainingChars > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
remaining(): number {
|
|
|
|
|
return this.remainingChars;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
take(content: string): string {
|
|
|
|
|
const clipped = content.slice(0, this.remainingChars);
|
|
|
|
|
this.remainingChars -= clipped.length;
|
|
|
|
|
return clipped;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAgentInstructions(accessMode: AccessMode): string {
|
|
|
|
|
return [
|
|
|
|
|
"You are running inside an Obsidian AI Sidebar plugin.",
|
|
|
|
|
"Use the provided vault context to answer the user.",
|
|
|
|
|
"If the prompt contains slash commands such as /writer or /reviewer, apply the matching selectedSkills instructions from the request context.",
|
|
|
|
|
"The request may include conversation memory and per-request options such as model or reasoning effort.",
|
|
|
|
|
"When you need file changes, include a JSON code block with this shape:",
|
|
|
|
|
'{"actions":[{"type":"edit","path":"Note.md","content":"new content"}]}',
|
|
|
|
|
"Allowed action types: create, edit, append, delete, rename.",
|
|
|
|
|
`Current access mode: ${accessMode}. The plugin enforces this mode before applying actions.`,
|
|
|
|
|
].join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
async function runProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {
|
2026-04-29 11:20:13 +00:00
|
|
|
if (provider.authType === "api-key") {
|
|
|
|
|
return runOpenAICompatibleProvider(provider, request);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (provider.authType === "oauth") {
|
|
|
|
|
if (!provider.accessToken) {
|
|
|
|
|
throw new Error("This OAuth provider is not connected yet.");
|
|
|
|
|
}
|
|
|
|
|
return runOAuthProvider(provider, request);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
return runCliProvider(provider, request, app);
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runOpenAICompatibleProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {
|
|
|
|
|
if (!provider.apiKey) throw new Error("Missing API key.");
|
|
|
|
|
const url = provider.apiBaseUrl || "https://api.openai.com/v1/responses";
|
|
|
|
|
const model = request.options.model || provider.model || "gpt-4.1-mini";
|
|
|
|
|
const userPayload = JSON.stringify({
|
|
|
|
|
prompt: request.prompt,
|
|
|
|
|
accessMode: request.accessMode,
|
|
|
|
|
context: request.context,
|
|
|
|
|
options: request.options,
|
|
|
|
|
});
|
|
|
|
|
const messages = [
|
|
|
|
|
{ role: "system", content: request.instructions },
|
|
|
|
|
...request.conversation.map((message) => ({
|
|
|
|
|
role: message.role === "assistant" ? "assistant" : "user",
|
|
|
|
|
content: message.content,
|
|
|
|
|
})),
|
|
|
|
|
{ role: "user", content: userPayload },
|
|
|
|
|
];
|
|
|
|
|
const usesChatCompletions = url.includes("/chat/completions");
|
|
|
|
|
const body: Record<string, unknown> = usesChatCompletions
|
|
|
|
|
? { model, messages }
|
|
|
|
|
: { model, input: messages };
|
|
|
|
|
if (request.options.reasoningEffort && model.startsWith("o")) {
|
|
|
|
|
body.reasoning = { effort: request.options.reasoningEffort };
|
|
|
|
|
}
|
|
|
|
|
const response = await requestUrl({
|
|
|
|
|
url,
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Authorization": `Bearer ${provider.apiKey}`,
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
throw: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.status < 200 || response.status >= 300) {
|
|
|
|
|
throw new Error(response.text || `HTTP ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return extractOpenAIText(response.json);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runOAuthProvider(provider: AgentProvider, request: AgentRequest): Promise<string> {
|
|
|
|
|
if (!provider.apiBaseUrl) {
|
|
|
|
|
throw new Error("OAuth provider needs an API endpoint after login.");
|
|
|
|
|
}
|
|
|
|
|
const response = await requestUrl({
|
|
|
|
|
url: provider.apiBaseUrl,
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Authorization": `Bearer ${provider.accessToken}`,
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(request),
|
|
|
|
|
throw: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.status < 200 || response.status >= 300) {
|
|
|
|
|
throw new Error(response.text || `HTTP ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:47:36 +00:00
|
|
|
const responseJson = response.json as { text?: unknown; output_text?: unknown } | undefined;
|
|
|
|
|
if (typeof responseJson?.text === "string") return responseJson.text;
|
|
|
|
|
if (typeof responseJson?.output_text === "string") return responseJson.output_text;
|
2026-04-29 11:20:13 +00:00
|
|
|
return response.text;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
function runCliProvider(provider: AgentProvider, request: AgentRequest, app: App): Promise<string> {
|
2026-04-29 11:20:13 +00:00
|
|
|
return new Promise((resolve, reject) => {
|
2026-04-29 11:39:23 +00:00
|
|
|
const prompt = cliPromptFromRequest(request);
|
|
|
|
|
const command = cliCommandForRequest(provider, request, app);
|
|
|
|
|
const shellCommand = `${command} ${shellQuote(prompt)}`;
|
|
|
|
|
const child = spawn("/bin/zsh", ["-lc", shellCommand], {
|
|
|
|
|
stdio: ["ignore", "pipe", "pipe"],
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let stdout = "";
|
|
|
|
|
let stderr = "";
|
|
|
|
|
|
|
|
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
|
|
|
stdout += chunk.toString();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
|
|
|
stderr += chunk.toString();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on("error", reject);
|
|
|
|
|
child.on("close", (code) => {
|
|
|
|
|
if (code === 0) {
|
|
|
|
|
resolve(stdout.trim());
|
|
|
|
|
} else {
|
2026-04-29 11:39:23 +00:00
|
|
|
reject(new Error(stderr.trim() || `Command exited with code ${code}. Check that ${command} is installed and available in your shell.`));
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
function cliCommandForRequest(provider: AgentProvider, request: AgentRequest, app: App): string {
|
|
|
|
|
const vaultPath = getVaultPath(app);
|
|
|
|
|
if (provider.id === "codex" || provider.command.startsWith("codex exec")) {
|
|
|
|
|
const sandbox = providerSandboxForAccessMode(request.accessMode);
|
|
|
|
|
const cdArg = vaultPath ? ` -C ${shellQuote(vaultPath)}` : "";
|
|
|
|
|
return `${provider.command}${cdArg} --skip-git-repo-check -s ${sandbox}`;
|
|
|
|
|
}
|
|
|
|
|
return provider.command;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function providerSandboxForAccessMode(accessMode: AccessMode): string {
|
|
|
|
|
if (accessMode === "full-access") return "workspace-write";
|
|
|
|
|
return "read-only";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getVaultPath(app: App): string | undefined {
|
|
|
|
|
const adapter = app.vault.adapter;
|
|
|
|
|
if ("basePath" in adapter && typeof adapter.basePath === "string") {
|
|
|
|
|
return adapter.basePath;
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cliPromptFromRequest(request: AgentRequest): string {
|
|
|
|
|
return [
|
|
|
|
|
request.instructions,
|
|
|
|
|
"",
|
|
|
|
|
"User request:",
|
|
|
|
|
request.prompt,
|
|
|
|
|
"",
|
|
|
|
|
"Vault context and options:",
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
accessMode: request.accessMode,
|
|
|
|
|
options: request.options,
|
|
|
|
|
conversation: request.options.memoryEnabled ? request.conversation : [],
|
|
|
|
|
context: request.context,
|
|
|
|
|
}, null, 2),
|
|
|
|
|
].join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openInteractiveTerminal(command: string, title: string): Promise<void> {
|
|
|
|
|
const escapedCommand = escapeForSingleQuotedShell(command);
|
|
|
|
|
const script = [
|
|
|
|
|
`tell application "Terminal"`,
|
|
|
|
|
"activate",
|
|
|
|
|
`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.'"`
|
|
|
|
|
,
|
|
|
|
|
"end tell",
|
|
|
|
|
].join("\n");
|
|
|
|
|
|
2026-04-29 11:20:13 +00:00
|
|
|
return new Promise((resolve, reject) => {
|
2026-04-29 11:39:23 +00:00
|
|
|
const child = spawn("osascript", ["-e", script], {
|
|
|
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
|
|
|
});
|
|
|
|
|
let stderr = "";
|
|
|
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
|
|
|
stderr += chunk.toString();
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
child.on("error", reject);
|
2026-04-29 11:39:23 +00:00
|
|
|
child.on("close", (code) => {
|
|
|
|
|
if (code === 0) resolve();
|
|
|
|
|
else reject(new Error(stderr.trim() || `Could not open Terminal. Exit code ${code}`));
|
|
|
|
|
});
|
2026-04-29 11:20:13 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractOpenAIText(json: unknown): string {
|
|
|
|
|
if (!json || typeof json !== "object") return "";
|
|
|
|
|
const response = json as {
|
|
|
|
|
output_text?: string;
|
|
|
|
|
output?: Array<{ content?: Array<{ text?: string; type?: string }> }>;
|
|
|
|
|
choices?: Array<{ message?: { content?: string } }>;
|
|
|
|
|
};
|
|
|
|
|
if (typeof response.output_text === "string") return response.output_text;
|
|
|
|
|
if (typeof response.choices?.[0]?.message?.content === "string") {
|
|
|
|
|
return response.choices[0].message.content;
|
|
|
|
|
}
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
for (const item of response.output ?? []) {
|
|
|
|
|
for (const content of item.content ?? []) {
|
|
|
|
|
if (typeof content.text === "string") parts.push(content.text);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return parts.join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:47:36 +00:00
|
|
|
function startOAuthLogin(provider: AgentProvider): boolean {
|
2026-04-29 11:20:13 +00:00
|
|
|
if (!provider.authorizationUrl || !provider.clientId) {
|
|
|
|
|
new Notice("Add an authorization URL and client ID first.");
|
2026-04-29 11:39:23 +00:00
|
|
|
return false;
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = new URL(provider.authorizationUrl);
|
|
|
|
|
url.searchParams.set("response_type", "token");
|
|
|
|
|
url.searchParams.set("client_id", provider.clientId);
|
|
|
|
|
url.searchParams.set("redirect_uri", "urn:ietf:wg:oauth:2.0:oob");
|
|
|
|
|
if (provider.scope) url.searchParams.set("scope", provider.scope);
|
|
|
|
|
url.searchParams.set("state", crypto.randomUUID());
|
|
|
|
|
|
|
|
|
|
window.open(url.toString());
|
2026-04-29 14:47:36 +00:00
|
|
|
new Notice("OAUTH sign-in opened in your browser. Paste the returned token into this provider when available.");
|
2026-04-29 11:39:23 +00:00
|
|
|
return true;
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
async function startProviderSignIn(provider: AgentProvider): Promise<boolean> {
|
2026-04-29 11:20:13 +00:00
|
|
|
if (provider.authType === "cli") {
|
|
|
|
|
if (!provider.signInCommand) {
|
|
|
|
|
new Notice("This local provider does not have a sign-in command configured.");
|
2026-04-29 11:39:23 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await openInteractiveTerminal(provider.signInCommand, `${provider.name} Sign In`);
|
|
|
|
|
new Notice(`Opened Terminal for ${provider.name} sign-in.`);
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
new Notice(`Could not open sign-in: ${message}`);
|
|
|
|
|
return false;
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (provider.authType === "api-key") {
|
|
|
|
|
if (provider.setupUrl) window.open(provider.setupUrl);
|
|
|
|
|
new Notice(provider.apiKey ? `${provider.name} is connected.` : `Add your ${provider.name} API key below to connect.`);
|
2026-04-29 11:39:23 +00:00
|
|
|
return Boolean(provider.apiKey);
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:39:23 +00:00
|
|
|
return startOAuthLogin(provider);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeForSingleQuotedShell(value: string): string {
|
|
|
|
|
return value.replace(/'/g, "'\\''");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shellQuote(value: string): string {
|
|
|
|
|
return `'${escapeForSingleQuotedShell(value)}'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeForAppleScript(value: string): string {
|
|
|
|
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function providerConnectionActionText(provider: AgentProvider): string {
|
2026-04-29 11:39:23 +00:00
|
|
|
if (provider.authType === "cli") return isProviderConnected(provider) ? "Reconnect" : "Sign in";
|
2026-04-29 11:20:13 +00:00
|
|
|
if (provider.authType === "api-key") return provider.apiKey ? "Connected" : "Get key";
|
|
|
|
|
return provider.accessToken ? "Reconnect" : "Sign in";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function providerConnectionLabel(provider: AgentProvider): string {
|
2026-04-29 11:39:23 +00:00
|
|
|
if (isProviderConnected(provider)) return "Connected";
|
|
|
|
|
if (provider.authType === "api-key") return "Needs API key";
|
|
|
|
|
if (provider.authType === "oauth") return "Needs sign-in";
|
|
|
|
|
return provider.signInCommand ? "Ready to sign in" : "Needs command";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isProviderConnected(provider: AgentProvider): boolean {
|
|
|
|
|
if (provider.authType === "api-key") return Boolean(provider.apiKey);
|
|
|
|
|
if (provider.authType === "oauth") return Boolean(provider.accessToken);
|
|
|
|
|
return Boolean(provider.connectedAt);
|
2026-04-29 11:20:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isProviderReady(provider: AgentProvider): boolean {
|
|
|
|
|
if (provider.authType === "api-key") return Boolean(provider.apiKey && provider.apiBaseUrl);
|
|
|
|
|
if (provider.authType === "oauth") return Boolean(provider.accessToken && provider.apiBaseUrl);
|
|
|
|
|
return Boolean(provider.command.trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeProviders(providers: AgentProvider[]): AgentProvider[] {
|
|
|
|
|
return providers.map((provider) => {
|
2026-04-29 14:47:36 +00:00
|
|
|
const command = provider.id === "codex" && provider.command === "codex"
|
2026-04-29 11:39:23 +00:00
|
|
|
? "codex exec"
|
2026-04-29 14:47:36 +00:00
|
|
|
: provider.command ?? "";
|
2026-04-29 11:20:13 +00:00
|
|
|
return {
|
2026-04-29 14:47:36 +00:00
|
|
|
...provider,
|
|
|
|
|
authType: provider.authType ?? (provider.command ? "cli" : "api-key"),
|
2026-04-29 11:39:23 +00:00
|
|
|
command,
|
2026-04-29 14:47:36 +00:00
|
|
|
connectedAt: provider.connectedAt,
|
2026-04-29 11:20:13 +00:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneProvider(provider: AgentProvider): AgentProvider {
|
|
|
|
|
return { ...provider };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractVaultActions(response: string): VaultAction[] {
|
|
|
|
|
const actions: VaultAction[] = [];
|
|
|
|
|
const codeBlockPattern = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
|
|
|
const candidates: string[] = [];
|
|
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
|
|
|
|
|
while ((match = codeBlockPattern.exec(response)) !== null) {
|
|
|
|
|
candidates.push(match[1]);
|
|
|
|
|
}
|
|
|
|
|
candidates.push(response);
|
|
|
|
|
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(candidate.trim()) as { actions?: VaultAction[] };
|
|
|
|
|
if (Array.isArray(parsed.actions)) {
|
|
|
|
|
actions.push(...parsed.actions.filter(isVaultAction));
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return actions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isVaultAction(value: unknown): value is VaultAction {
|
|
|
|
|
if (!value || typeof value !== "object") return false;
|
|
|
|
|
const action = value as Partial<VaultAction>;
|
|
|
|
|
if (typeof action.type !== "string" || typeof action.path !== "string") return false;
|
|
|
|
|
if (["create", "edit", "append"].includes(action.type)) {
|
|
|
|
|
return typeof (action as { content?: unknown }).content === "string";
|
|
|
|
|
}
|
|
|
|
|
if (action.type === "delete") return true;
|
|
|
|
|
if (action.type === "rename") {
|
|
|
|
|
return typeof (action as { newPath?: unknown }).newPath === "string";
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function confirmVaultActions(app: App, actions: VaultAction[]): Promise<boolean> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
new ConfirmActionsModal(app, actions, resolve).open();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyVaultAction(app: App, action: VaultAction): Promise<void> {
|
|
|
|
|
const path = normalizeVaultPath(action.path);
|
|
|
|
|
const file = app.vault.getAbstractFileByPath(path);
|
|
|
|
|
|
|
|
|
|
if (action.type === "create") {
|
|
|
|
|
if (file) throw new Error("File already exists.");
|
|
|
|
|
await ensureParentFolder(app, path);
|
|
|
|
|
await app.vault.create(path, action.content);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!(file instanceof TFile)) {
|
|
|
|
|
throw new Error("File does not exist.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action.type === "edit") {
|
|
|
|
|
await app.vault.modify(file, action.content);
|
|
|
|
|
} else if (action.type === "append") {
|
|
|
|
|
const current = await app.vault.read(file);
|
|
|
|
|
await app.vault.modify(file, `${current}${current.endsWith("\n") ? "" : "\n"}${action.content}`);
|
|
|
|
|
} else if (action.type === "delete") {
|
2026-04-29 14:47:36 +00:00
|
|
|
await app.fileManager.trashFile(file);
|
2026-04-29 11:20:13 +00:00
|
|
|
} else if (action.type === "rename") {
|
|
|
|
|
await ensureParentFolder(app, action.newPath);
|
|
|
|
|
await app.fileManager.renameFile(file, normalizeVaultPath(action.newPath));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureParentFolder(app: App, path: string): Promise<void> {
|
|
|
|
|
const parts = normalizeVaultPath(path).split("/");
|
|
|
|
|
parts.pop();
|
|
|
|
|
let current = "";
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
current = current ? `${current}/${part}` : part;
|
|
|
|
|
if (!app.vault.getAbstractFileByPath(current)) {
|
|
|
|
|
await app.vault.createFolder(current);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeVaultPath(path: string): string {
|
|
|
|
|
return path.replace(/^\/+/, "").replace(/\\/g, "/");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function uniqueFiles(files: TFile[]): TFile[] {
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
return files.filter((file) => {
|
|
|
|
|
if (seen.has(file.path)) return false;
|
|
|
|
|
seen.add(file.path);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isReasoningEffort(value: string): value is ReasoningEffort {
|
|
|
|
|
return value === "low" || value === "medium" || value === "high";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAccessMode(value: string): value is AccessMode {
|
|
|
|
|
return value === "read-only" || value === "confirm" || value === "full-access";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeRegExp(value: string): string {
|
|
|
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
|
}
|