a573d33d0a
Replace the pending-actions preview flow with immediate execution and undo support. ToolExecutor now accepts an UndoManager and records create, modify, rename, and trash operations so users can roll back batches. Other fixes included: - Deep-merge nested config objects on settings load to preserve new default fields - Increase retry backoff from 10ms to 1000ms and widen the "invalid response format" check to handle prefixed messages - Fix semantic cache clear to null out the collection reference - Tighten memory regex to require "please always/never" - Increase vault indexing batch size from 1 to 5 - Remove unused modeRequiresPreview helper
1146 lines
41 KiB
TypeScript
Executable File
1146 lines
41 KiB
TypeScript
Executable File
import { Plugin, WorkspaceLeaf, App, Setting, Notice, PluginSettingTab, TFile } from 'obsidian';
|
||
import { ChatView } from './chat-view';
|
||
import { DEFAULT_SETTINGS } from './constants';
|
||
import { SemanticCacheService } from './semantic-cache';
|
||
import { VaultVectorStore } from './vault-vector-store';
|
||
import { VaultIndexer } from './vault-indexer';
|
||
import { AutoTagger, AutoLinker } from './auto-organizer';
|
||
import { PluginSettings, StructuredMemoryData, ToolTelemetryData, ChatHistoryData, AgentMode } from './types';
|
||
import { Logger } from './utils';
|
||
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
|
||
import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory';
|
||
import { TelemetryManager, createDefaultToolTelemetryData } from './tool-telemetry';
|
||
import { ChatHistoryManager, createDefaultChatHistoryData } from './chat-history';
|
||
|
||
export default class OllamaPlugin extends Plugin {
|
||
settings: PluginSettings = DEFAULT_SETTINGS;
|
||
semanticCache?: SemanticCacheService;
|
||
vaultVectorStore?: VaultVectorStore;
|
||
autoTagger?: AutoTagger;
|
||
autoLinker?: AutoLinker;
|
||
structuredMemoryManager?: StructuredMemoryManager;
|
||
telemetryManager?: TelemetryManager;
|
||
chatHistoryManager?: ChatHistoryManager;
|
||
private indexingAbortController?: AbortController;
|
||
private currentIndexingPromise?: Promise<void>;
|
||
private saveSettingsTimer?: ReturnType<typeof setTimeout>;
|
||
|
||
async onload() {
|
||
await this.loadSettings();
|
||
|
||
// Initialize vault vector store if enabled
|
||
if (this.settings.vaultIndexConfig.enabled) {
|
||
await this.initializeVaultVectorStore();
|
||
}
|
||
|
||
// Register the chat view
|
||
this.registerView(
|
||
'ollama-chat-view',
|
||
(leaf: WorkspaceLeaf) =>
|
||
new ChatView(
|
||
leaf,
|
||
this.settings,
|
||
this.vaultVectorStore,
|
||
this.structuredMemoryManager,
|
||
this.telemetryManager,
|
||
this.chatHistoryManager,
|
||
(model: string) => {
|
||
this.settings.chatModel = model;
|
||
this.settings.agentModel = model;
|
||
this.settings.model = model;
|
||
void this.saveSettings();
|
||
this.notifyChatViews();
|
||
},
|
||
() => this.scheduleSaveSettings()
|
||
)
|
||
);
|
||
|
||
// Add a ribbon icon in the left sidebar
|
||
this.addRibbonIcon('bot', 'Open Ollama Chat', async () => {
|
||
await this.activateChatView();
|
||
});
|
||
|
||
// Add a command to open the chat view
|
||
this.addCommand({
|
||
id: 'open-ollama-chat',
|
||
name: 'Open Ollama Chat',
|
||
callback: async () => {
|
||
await this.activateChatView();
|
||
},
|
||
});
|
||
|
||
// Add a command to clear the semantic cache
|
||
this.addCommand({
|
||
id: 'clear-semantic-cache',
|
||
name: 'Clear Semantic Cache',
|
||
callback: async () => {
|
||
await this.clearSemanticCache();
|
||
new Notice('Semantic cache cleared.');
|
||
},
|
||
});
|
||
|
||
// Add a command to clear the vault index
|
||
this.addCommand({
|
||
id: 'clear-vault-index',
|
||
name: 'Clear Vault Index',
|
||
callback: async () => {
|
||
await this.clearVaultIndex();
|
||
new Notice('Vault index cleared.');
|
||
},
|
||
});
|
||
|
||
// Add a command to rebuild the vault index
|
||
this.addCommand({
|
||
id: 'rebuild-vault-index',
|
||
name: 'Rebuild Vault Index',
|
||
callback: async () => {
|
||
new Notice('Rebuilding vault index...');
|
||
await this.rebuildVaultIndex();
|
||
new Notice('Vault index rebuilt.');
|
||
},
|
||
});
|
||
|
||
// Add a command to auto-tag untagged notes
|
||
this.addCommand({
|
||
id: 'auto-tag-notes',
|
||
name: 'Auto-Tag Untagged Notes',
|
||
callback: () => {
|
||
this.initializeAutoOrganizer();
|
||
if (this.autoTagger) {
|
||
new Notice('Auto-tagging untagged notes...');
|
||
void this.autoTagger.run();
|
||
}
|
||
},
|
||
});
|
||
|
||
// Add a command to auto-link related notes
|
||
this.addCommand({
|
||
id: 'auto-link-notes',
|
||
name: 'Auto-Link Related Notes',
|
||
callback: () => {
|
||
this.initializeAutoOrganizer();
|
||
if (this.autoLinker) {
|
||
new Notice('Auto-linking related notes...');
|
||
void this.autoLinker.run(this.settings.autoLinkConfig.dryRun);
|
||
}
|
||
},
|
||
});
|
||
|
||
// Add a command to clear the structured memory
|
||
this.addCommand({
|
||
id: 'clear-structured-memory',
|
||
name: 'Clear Structured Memory',
|
||
callback: async () => {
|
||
this.structuredMemoryManager?.clearAll();
|
||
await this.saveSettings();
|
||
new Notice('Structured memory cleared.');
|
||
},
|
||
});
|
||
|
||
// Add a command to clear tool telemetry
|
||
this.addCommand({
|
||
id: 'clear-tool-telemetry',
|
||
name: 'Clear Tool Telemetry',
|
||
callback: async () => {
|
||
this.telemetryManager?.clear();
|
||
await this.saveSettings();
|
||
new Notice('Tool telemetry cleared.');
|
||
},
|
||
});
|
||
|
||
// Add a command to clear chat history
|
||
this.addCommand({
|
||
id: 'clear-chat-history',
|
||
name: 'Clear Chat History',
|
||
callback: async () => {
|
||
this.chatHistoryManager?.clearAll();
|
||
await this.saveSettings();
|
||
new Notice('Chat history cleared.');
|
||
this.notifyChatViews();
|
||
},
|
||
});
|
||
this.addSettingTab(new OllamaSettingTab(this.app, this));
|
||
|
||
// Initialize the semantic cache
|
||
if (this.settings.cacheConfig) {
|
||
this.semanticCache = new SemanticCacheService(
|
||
this.settings.ollamaUrl,
|
||
this.settings.cacheConfig
|
||
);
|
||
try {
|
||
await this.semanticCache.initialize();
|
||
} catch {
|
||
new Notice('Semantic cache initialization failed. Check console for details.');
|
||
}
|
||
}
|
||
|
||
// Set up vault event listeners for incremental indexing
|
||
this.registerVaultEventListeners();
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||
onunload() {
|
||
// Cancel any ongoing indexing
|
||
this.cancelBackgroundIndexing();
|
||
|
||
if (this.saveSettingsTimer) {
|
||
clearTimeout(this.saveSettingsTimer);
|
||
this.saveSettingsTimer = undefined;
|
||
}
|
||
void this.saveSettings();
|
||
// No explicit unregisterView needed; relying on Obsidian lifecycle management.
|
||
}
|
||
|
||
async loadSettings() {
|
||
const data = ((await this.loadData()) ?? {}) as Record<string, unknown>;
|
||
// Backward compatibility: old flat format vs new nested format
|
||
const loadedSettings = (data.settings ?? data) as Partial<PluginSettings>;
|
||
// Deep merge nested config objects so new default fields are preserved
|
||
this.settings = {
|
||
...DEFAULT_SETTINGS,
|
||
...loadedSettings,
|
||
cacheConfig: { ...DEFAULT_SETTINGS.cacheConfig, ...(loadedSettings.cacheConfig ?? {}) },
|
||
vaultIndexConfig: { ...DEFAULT_SETTINGS.vaultIndexConfig, ...(loadedSettings.vaultIndexConfig ?? {}) },
|
||
autoTagConfig: { ...DEFAULT_SETTINGS.autoTagConfig, ...(loadedSettings.autoTagConfig ?? {}) },
|
||
autoLinkConfig: { ...DEFAULT_SETTINGS.autoLinkConfig, ...(loadedSettings.autoLinkConfig ?? {}) },
|
||
structuredMemoryConfig: { ...DEFAULT_SETTINGS.structuredMemoryConfig, ...(loadedSettings.structuredMemoryConfig ?? {}) },
|
||
toolTelemetryConfig: { ...DEFAULT_SETTINGS.toolTelemetryConfig, ...(loadedSettings.toolTelemetryConfig ?? {}) },
|
||
};
|
||
const legacyModel = loadedSettings.model ?? DEFAULT_SETTINGS.model;
|
||
this.settings.chatModel = loadedSettings.chatModel ?? legacyModel;
|
||
this.settings.agentModel = loadedSettings.agentModel ?? legacyModel;
|
||
this.settings.model = this.settings.chatModel;
|
||
|
||
const memoryData: StructuredMemoryData =
|
||
(data.structuredMemory as StructuredMemoryData | undefined) ??
|
||
createDefaultStructuredMemoryData();
|
||
this.structuredMemoryManager = new StructuredMemoryManager(
|
||
this.settings.structuredMemoryConfig,
|
||
memoryData
|
||
);
|
||
|
||
const telemetryData: ToolTelemetryData =
|
||
(data.toolTelemetry as ToolTelemetryData | undefined) ?? createDefaultToolTelemetryData();
|
||
this.telemetryManager = new TelemetryManager(this.settings.toolTelemetryConfig, telemetryData);
|
||
|
||
const historyData: ChatHistoryData =
|
||
(data.chatHistory as ChatHistoryData | undefined) ?? createDefaultChatHistoryData();
|
||
this.chatHistoryManager = new ChatHistoryManager(historyData);
|
||
}
|
||
|
||
async saveSettings() {
|
||
await this.saveData({
|
||
settings: this.settings,
|
||
structuredMemory:
|
||
this.structuredMemoryManager?.getData() ?? createDefaultStructuredMemoryData(),
|
||
toolTelemetry: this.telemetryManager?.getData() ?? createDefaultToolTelemetryData(),
|
||
chatHistory: this.chatHistoryManager?.getData() ?? createDefaultChatHistoryData(),
|
||
});
|
||
}
|
||
|
||
private scheduleSaveSettings(delayMs = 500): void {
|
||
if (this.saveSettingsTimer) {
|
||
clearTimeout(this.saveSettingsTimer);
|
||
}
|
||
this.saveSettingsTimer = setTimeout(() => {
|
||
this.saveSettingsTimer = undefined;
|
||
void this.saveSettings().catch((error) => {
|
||
Logger.warn(`Failed to persist plugin data: ${String(error)}`, 'main');
|
||
});
|
||
}, delayMs);
|
||
}
|
||
|
||
initializeAutoOrganizer(): void {
|
||
this.autoTagger = new AutoTagger(
|
||
this.app.vault,
|
||
this.app,
|
||
this.settings.ollamaUrl,
|
||
this.settings.agentModel,
|
||
this.settings.autoTagConfig
|
||
);
|
||
|
||
if (!this.autoLinker) {
|
||
const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore);
|
||
vaultIndexer.setApp(this.app);
|
||
this.autoLinker = new AutoLinker(
|
||
this.app.vault,
|
||
vaultIndexer,
|
||
this.settings.autoLinkConfig,
|
||
this.settings.autoLinkConfig.targetFolder
|
||
);
|
||
} else {
|
||
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
|
||
}
|
||
}
|
||
|
||
async initializeVaultVectorStore(): Promise<void> {
|
||
// Abort any ongoing indexing before re-initializing
|
||
this.cancelBackgroundIndexing();
|
||
await this.awaitBackgroundIndexing();
|
||
|
||
this.vaultVectorStore = new VaultVectorStore(
|
||
this.settings.ollamaUrl,
|
||
this.settings.vaultIndexConfig
|
||
);
|
||
try {
|
||
await this.vaultVectorStore.initialize();
|
||
// Run background indexing
|
||
this.currentIndexingPromise = this.backgroundIndexVault();
|
||
void this.currentIndexingPromise
|
||
.catch((err) => {
|
||
Logger.warn(`Background vault indexing failed: ${String(err)}`, 'main');
|
||
})
|
||
.finally(() => {
|
||
this.currentIndexingPromise = undefined;
|
||
this.indexingAbortController = undefined;
|
||
});
|
||
} catch {
|
||
new Notice('Vault vector store initialization failed. Check console for details.');
|
||
}
|
||
}
|
||
|
||
private cancelBackgroundIndexing(): void {
|
||
if (this.indexingAbortController) {
|
||
this.indexingAbortController.abort();
|
||
}
|
||
}
|
||
|
||
private async awaitBackgroundIndexing(): Promise<void> {
|
||
if (this.currentIndexingPromise) {
|
||
try {
|
||
await this.currentIndexingPromise;
|
||
} catch {
|
||
// ignore errors from cancelled indexing
|
||
}
|
||
}
|
||
}
|
||
|
||
async backgroundIndexVault(): Promise<void> {
|
||
if (!this.vaultVectorStore) return;
|
||
|
||
this.indexingAbortController = new AbortController();
|
||
const signal = this.indexingAbortController.signal;
|
||
|
||
const files = this.app.vault.getMarkdownFiles();
|
||
Logger.info(`Starting background vault indexing for ${files.length} files...`, 'main');
|
||
|
||
let indexed = 0;
|
||
const BATCH_SIZE = 5;
|
||
const DELAY_MS = 500;
|
||
|
||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
||
if (signal.aborted) {
|
||
Logger.info('Vault indexing cancelled.', 'main');
|
||
return;
|
||
}
|
||
|
||
const batch = files.slice(i, i + BATCH_SIZE);
|
||
// Process files sequentially to avoid concurrent embedding requests
|
||
for (const file of batch) {
|
||
if (signal.aborted) break;
|
||
try {
|
||
const content = await this.app.vault.read(file);
|
||
if (signal.aborted) break;
|
||
const cache = this.app.metadataCache.getFileCache(file);
|
||
await this.vaultVectorStore.indexFile(file, content, cache ?? undefined);
|
||
indexed++;
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
Logger.warn(`Failed to index ${file.path}: ${errorMessage}`, 'main');
|
||
}
|
||
}
|
||
|
||
// Delay between batches to avoid overloading Ollama
|
||
if (i + BATCH_SIZE < files.length) {
|
||
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
|
||
}
|
||
}
|
||
|
||
if (!signal.aborted) {
|
||
this.settings.lastIndexTime = Date.now();
|
||
await this.saveSettings();
|
||
Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, 'main');
|
||
new Notice(`Vault index updated: ${indexed} files indexed.`);
|
||
}
|
||
}
|
||
|
||
async rebuildVaultIndex(): Promise<void> {
|
||
// Cancel and await any ongoing indexing before clearing
|
||
this.cancelBackgroundIndexing();
|
||
await this.awaitBackgroundIndexing();
|
||
|
||
if (this.vaultVectorStore) {
|
||
await this.vaultVectorStore.clearIndex();
|
||
await this.vaultVectorStore.initialize();
|
||
}
|
||
|
||
this.currentIndexingPromise = this.backgroundIndexVault();
|
||
void this.currentIndexingPromise
|
||
.catch((err) => {
|
||
Logger.warn(`Rebuild vault indexing failed: ${String(err)}`, 'main');
|
||
})
|
||
.finally(() => {
|
||
this.currentIndexingPromise = undefined;
|
||
this.indexingAbortController = undefined;
|
||
});
|
||
}
|
||
|
||
async clearVaultIndex(): Promise<void> {
|
||
if (this.vaultVectorStore) {
|
||
await this.vaultVectorStore.clearIndex();
|
||
}
|
||
}
|
||
|
||
private indexVaultFileWhenReady(file: TFile): void {
|
||
void (async () => {
|
||
await this.awaitBackgroundIndexing();
|
||
|
||
if (!this.vaultVectorStore) return;
|
||
|
||
const currentFile = this.app.vault.getAbstractFileByPath(file.path);
|
||
if (!(currentFile instanceof TFile) || currentFile.extension !== 'md') return;
|
||
|
||
const content = await this.app.vault.read(currentFile);
|
||
const cache = this.app.metadataCache.getFileCache(currentFile);
|
||
await this.vaultVectorStore.indexFile(currentFile, content, cache ?? undefined);
|
||
})().catch((error) => {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
Logger.warn(`Failed to update vault index for ${file.path}: ${errorMessage}`, 'main');
|
||
});
|
||
}
|
||
|
||
registerVaultEventListeners(): void {
|
||
// Listen for file creation
|
||
this.registerEvent(
|
||
this.app.vault.on('create', (file) => {
|
||
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
|
||
this.indexVaultFileWhenReady(file);
|
||
}
|
||
})
|
||
);
|
||
|
||
// Listen for file modification
|
||
this.registerEvent(
|
||
this.app.vault.on('modify', (file) => {
|
||
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
|
||
this.indexVaultFileWhenReady(file);
|
||
}
|
||
})
|
||
);
|
||
|
||
// Listen for file deletion
|
||
this.registerEvent(
|
||
this.app.vault.on('delete', (file) => {
|
||
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
|
||
void this.vaultVectorStore.deleteFile(file.path);
|
||
}
|
||
})
|
||
);
|
||
|
||
// Listen for file renames
|
||
this.registerEvent(
|
||
this.app.vault.on('rename', (file, oldPath) => {
|
||
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
|
||
void this.vaultVectorStore.deleteFile(oldPath);
|
||
this.indexVaultFileWhenReady(file);
|
||
}
|
||
})
|
||
);
|
||
}
|
||
|
||
async activateChatView() {
|
||
const existing = this.app.workspace.getLeavesOfType('ollama-chat-view');
|
||
if (existing.length > 0) {
|
||
await this.app.workspace.revealLeaf(existing[0]);
|
||
} else {
|
||
const leaf = this.app.workspace.getRightLeaf(false);
|
||
if (leaf) {
|
||
await leaf.setViewState({
|
||
type: 'ollama-chat-view',
|
||
active: true,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async clearSemanticCache() {
|
||
if (this.semanticCache) {
|
||
await this.semanticCache.clearCache();
|
||
}
|
||
}
|
||
|
||
notifyChatViews() {
|
||
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
|
||
leaves.forEach((leaf) => {
|
||
if (leaf.view instanceof ChatView) {
|
||
leaf.view.updateSettings(this.settings);
|
||
leaf.view.setVectorStore(this.vaultVectorStore);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
class OllamaSettingTab extends PluginSettingTab {
|
||
plugin: OllamaPlugin;
|
||
|
||
constructor(app: App, plugin: OllamaPlugin) {
|
||
super(app, plugin);
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
display() {
|
||
const { containerEl } = this;
|
||
containerEl.empty();
|
||
containerEl.createEl('h2', { text: 'Ollama Settings' });
|
||
|
||
new Setting(containerEl)
|
||
.setName('Ollama URL')
|
||
.setDesc('URL for your Ollama instance (default: http://localhost:11434)')
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
|
||
this.plugin.settings.ollamaUrl = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Chat Model')
|
||
.setDesc('Model for normal chat, Ask mode, and Research mode (default: deepseek-v4-flash)')
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.chatModel).onChange(async (value) => {
|
||
this.plugin.settings.chatModel = value.trim();
|
||
this.plugin.settings.model = this.plugin.settings.chatModel;
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Agent Model')
|
||
.setDesc('Model for Edit, Organize, Workflow, and auto-organizer tasks (default: glm-5.1)')
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.agentModel).onChange(async (value) => {
|
||
this.plugin.settings.agentModel = value.trim();
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Vault Search Limit')
|
||
.setDesc('Maximum number of vault entries to include in context (default: 5)')
|
||
.addText((text) =>
|
||
text.setValue(String(this.plugin.settings.vaultSearchLimit)).onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0) {
|
||
this.plugin.settings.vaultSearchLimit = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Vault search limit must be a positive integer.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Context Length')
|
||
.setDesc('Maximum characters of vault content to send to the AI per message (default: 8000)')
|
||
.addText((text) =>
|
||
text.setValue(String(this.plugin.settings.maxContextLength)).onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0) {
|
||
this.plugin.settings.maxContextLength = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max context length must be a positive integer.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Message History')
|
||
.setDesc('Maximum number of messages to keep in conversation history (default: 50)')
|
||
.addText((text) =>
|
||
text.setValue(String(this.plugin.settings.maxMessageHistory)).onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0) {
|
||
this.plugin.settings.maxMessageHistory = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max message history must be a positive integer.');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Agent Mode Setting
|
||
containerEl.createEl('h3', { text: 'Agent Mode' });
|
||
containerEl.createEl('p', {
|
||
text: 'Default chat mode that controls available tools and system behavior.',
|
||
});
|
||
|
||
new Setting(containerEl)
|
||
.setName('Default Agent Mode')
|
||
.setDesc('Select the default mode for new chat sessions.')
|
||
.addDropdown((dropdown) => {
|
||
for (const mode of ALL_AGENT_MODES) {
|
||
dropdown.addOption(mode, getAgentModeLabel(mode));
|
||
}
|
||
dropdown.setValue(this.plugin.settings.agentMode ?? 'ask');
|
||
dropdown.onChange(async (value) => {
|
||
this.plugin.settings.agentMode = value as AgentMode;
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
});
|
||
});
|
||
|
||
// Vault Index Settings
|
||
containerEl.createEl('h3', { text: 'Vault Semantic Index' });
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Vault Semantic Index')
|
||
.setDesc(
|
||
'Automatically index vault notes into a vector database for semantic/RAG search. Requires ChromaDB and an embedding model.'
|
||
)
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(this.plugin.settings.vaultIndexConfig.enabled).onChange(async (value) => {
|
||
this.plugin.settings.vaultIndexConfig.enabled = value;
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
if (value) {
|
||
new Notice('Vault semantic index enabled. Rebuilding index...');
|
||
await this.plugin.initializeVaultVectorStore();
|
||
await this.plugin.rebuildVaultIndex();
|
||
} else {
|
||
await this.plugin.clearVaultIndex();
|
||
this.plugin.vaultVectorStore = undefined;
|
||
this.plugin.notifyChatViews();
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Vault Index ChromaDB URL')
|
||
.setDesc(
|
||
'URL for your ChromaDB instance used for the vault index (default: http://localhost:8000)'
|
||
)
|
||
.addText((text) =>
|
||
text
|
||
.setValue(this.plugin.settings.vaultIndexConfig.chromaURL || 'http://localhost:8000')
|
||
.onChange(async (value) => {
|
||
const trimmed = value.trim();
|
||
this.plugin.settings.vaultIndexConfig.chromaURL =
|
||
trimmed && trimmed.includes('://') ? trimmed : 'http://localhost:8000';
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Vault Index Embedding Model')
|
||
.setDesc(
|
||
'Ollama model used to generate embeddings for vault notes (default: nomic-embed-text)'
|
||
)
|
||
.addText((text) =>
|
||
text
|
||
.setValue(this.plugin.settings.vaultIndexConfig.embeddingModel)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.vaultIndexConfig.embeddingModel = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Vault Index Similarity Threshold')
|
||
.setDesc(
|
||
'Minimum cosine similarity (0–1) for a vault search hit. Higher values require closer matches (default: 0.75).'
|
||
)
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.vaultIndexConfig.similarityThreshold))
|
||
.onChange(async (value) => {
|
||
const parsed = parseFloat(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||
this.plugin.settings.vaultIndexConfig.similarityThreshold = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Similarity threshold must be a number between 0 and 1.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Rebuild Vault Index')
|
||
.setDesc('Delete and rebuild the entire vault semantic index')
|
||
.addButton((button) =>
|
||
button.setButtonText('Rebuild Index').onClick(async () => {
|
||
try {
|
||
new Notice('Rebuilding vault index...');
|
||
await this.plugin.rebuildVaultIndex();
|
||
new Notice('Vault index rebuilt.');
|
||
} catch {
|
||
new Notice('Failed to rebuild vault index. Is ChromaDB running?');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Clear Vault Index')
|
||
.setDesc('Delete all indexed vault notes from ChromaDB')
|
||
.addButton((button) =>
|
||
button.setButtonText('Clear Index').onClick(async () => {
|
||
try {
|
||
await this.plugin.clearVaultIndex();
|
||
new Notice('Vault index cleared.');
|
||
} catch {
|
||
new Notice('Failed to clear vault index. Is ChromaDB running?');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Semantic Cache Settings
|
||
containerEl.createEl('h3', { text: 'Semantic Cache' });
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Semantic Cache')
|
||
.setDesc('Use semantic cache to store and retrieve previous responses')
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(this.plugin.settings.cacheConfig.enabled).onChange(async (value) => {
|
||
this.plugin.settings.cacheConfig.enabled = value;
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('ChromaDB URL')
|
||
.setDesc('URL for your ChromaDB instance (default: http://localhost:8000)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(this.plugin.settings.cacheConfig.chromaURL || 'http://localhost:8000')
|
||
.onChange(async (value) => {
|
||
const trimmed = value.trim();
|
||
// Ensure a valid-looking URL; fall back to default if empty or malformed
|
||
this.plugin.settings.cacheConfig.chromaURL =
|
||
trimmed && trimmed.includes('://') ? trimmed : 'http://localhost:8000';
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Cache Embedding Model')
|
||
.setDesc('Ollama model used to generate embeddings for the semantic cache')
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.cacheConfig.embeddingModel).onChange(async (value) => {
|
||
this.plugin.settings.cacheConfig.embeddingModel = value;
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Cache Similarity Threshold')
|
||
.setDesc(
|
||
'Minimum cosine similarity (0–1) for a cache hit. Higher values require closer matches.'
|
||
)
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.cacheConfig.similarityThreshold))
|
||
.onChange(async (value) => {
|
||
const parsed = parseFloat(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||
this.plugin.settings.cacheConfig.similarityThreshold = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Similarity threshold must be a number between 0 and 1.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Clear Semantic Cache')
|
||
.setDesc('Delete all cached responses from ChromaDB')
|
||
.addButton((button) =>
|
||
button.setButtonText('Clear Cache').onClick(async () => {
|
||
try {
|
||
await this.plugin.clearSemanticCache();
|
||
new Notice('Semantic cache cleared.');
|
||
} catch {
|
||
new Notice('Failed to clear semantic cache. Is ChromaDB running?');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Auto-Organize Settings
|
||
containerEl.createEl('h3', { text: 'Auto-Organize' });
|
||
|
||
// Auto-Tag Settings
|
||
containerEl.createEl('h4', { text: 'Auto-Tagging' });
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Auto-Tagging')
|
||
.setDesc('Use AI to automatically suggest and apply tags to untagged notes')
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(this.plugin.settings.autoTagConfig.enabled).onChange(async (value) => {
|
||
this.plugin.settings.autoTagConfig.enabled = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Tags Per Note')
|
||
.setDesc('Maximum number of tags to generate for each note (default: 5)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoTagConfig.maxTagsPerNote))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0 && parsed <= 20) {
|
||
this.plugin.settings.autoTagConfig.maxTagsPerNote = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max tags must be between 1 and 20.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Min Note Length')
|
||
.setDesc('Minimum character length for a note to be tagged (default: 50)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoTagConfig.minNoteLength))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0) {
|
||
this.plugin.settings.autoTagConfig.minNoteLength = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Min note length must be a non-negative integer.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Note Length')
|
||
.setDesc('Maximum characters of content sent to the model for tagging (default: 8000)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoTagConfig.maxNoteLength))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0) {
|
||
this.plugin.settings.autoTagConfig.maxNoteLength = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max note length must be a positive integer.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Normalize Tags')
|
||
.setDesc(
|
||
'Normalize generated tags against existing vault tag vocabulary (e.g., prefer "machine-learning" over "machine learning")'
|
||
)
|
||
.addToggle((toggle) =>
|
||
toggle
|
||
.setValue(this.plugin.settings.autoTagConfig.normalizeTags)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.autoTagConfig.normalizeTags = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Target Folder (Auto-Tag)')
|
||
.setDesc('Only auto-tag notes inside this folder path. Leave empty for all notes.')
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.autoTagConfig.targetFolder).onChange(async (value) => {
|
||
this.plugin.settings.autoTagConfig.targetFolder = value.trim();
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Tag Prompt Template')
|
||
.setDesc(
|
||
'Prompt template for tag generation. Use {{maxTags}}, {{title}}, {{content}} as placeholders.'
|
||
)
|
||
.addTextArea((text) =>
|
||
text
|
||
.setValue(this.plugin.settings.autoTagConfig.tagPromptTemplate)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.autoTagConfig.tagPromptTemplate = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Run Auto-Tagging Now')
|
||
.setDesc('Process all untagged notes and generate tags')
|
||
.addButton((button) =>
|
||
button.setButtonText('Auto-Tag Notes').onClick(async () => {
|
||
try {
|
||
this.plugin.initializeAutoOrganizer();
|
||
if (this.plugin.autoTagger) {
|
||
await this.plugin.autoTagger.run();
|
||
}
|
||
} catch {
|
||
new Notice('Auto-tagging failed. Check console for details.');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Auto-Link Settings
|
||
containerEl.createEl('h4', { text: 'Auto-Linking' });
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Auto-Linking')
|
||
.setDesc('Add "Related Notes" sections to notes based on semantic similarity')
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(this.plugin.settings.autoLinkConfig.enabled).onChange(async (value) => {
|
||
this.plugin.settings.autoLinkConfig.enabled = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Links Per Note')
|
||
.setDesc('Maximum number of related notes to link (default: 3)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoLinkConfig.maxLinksPerNote))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0 && parsed <= 10) {
|
||
this.plugin.settings.autoLinkConfig.maxLinksPerNote = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max links must be between 1 and 10.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Target Folder (Auto-Link)')
|
||
.setDesc(
|
||
'Only add related links to notes inside this folder path. Leave empty for all notes.'
|
||
)
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.autoLinkConfig.targetFolder).onChange(async (value) => {
|
||
this.plugin.settings.autoLinkConfig.targetFolder = value.trim();
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Dry Run Mode')
|
||
.setDesc('Preview proposed link changes without applying them')
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(this.plugin.settings.autoLinkConfig.dryRun).onChange(async (value) => {
|
||
this.plugin.settings.autoLinkConfig.dryRun = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Auto-Link Similarity Threshold')
|
||
.setDesc('Minimum similarity score for notes to be considered related (default: 0.6)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoLinkConfig.similarityThreshold))
|
||
.onChange(async (value) => {
|
||
const parsed = parseFloat(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||
this.plugin.settings.autoLinkConfig.similarityThreshold = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Similarity threshold must be between 0 and 1.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Run Auto-Linking Now')
|
||
.setDesc('Process all notes and add related note links')
|
||
.addButton((button) =>
|
||
button.setButtonText('Auto-Link Notes').onClick(async () => {
|
||
try {
|
||
this.plugin.initializeAutoOrganizer();
|
||
if (this.plugin.autoLinker) {
|
||
await this.plugin.autoLinker.run(this.plugin.settings.autoLinkConfig.dryRun);
|
||
}
|
||
} catch {
|
||
new Notice('Auto-linking failed. Check console for details.');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Structured Memory Settings
|
||
containerEl.createEl('h3', { text: 'Structured Memory' });
|
||
containerEl.createEl('p', {
|
||
text: 'Persist conversation summaries, user preferences, and learned facts across sessions.',
|
||
});
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Structured Memory')
|
||
.setDesc('Inject remembered context from past sessions into the system prompt.')
|
||
.addToggle((toggle) =>
|
||
toggle
|
||
.setValue(this.plugin.settings.structuredMemoryConfig.enabled)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.structuredMemoryConfig.enabled = value;
|
||
this.plugin.structuredMemoryManager!.updateConfig(
|
||
this.plugin.settings.structuredMemoryConfig
|
||
);
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Conversation Summaries')
|
||
.setDesc('Maximum number of past conversation summaries to retain (default: 10).')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxSummaries))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
|
||
this.plugin.settings.structuredMemoryConfig.maxSummaries = parsed;
|
||
this.plugin.structuredMemoryManager!.updateConfig(
|
||
this.plugin.settings.structuredMemoryConfig
|
||
);
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max summaries must be between 0 and 100.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max User Preferences')
|
||
.setDesc('Maximum number of user preferences to retain (default: 20).')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxPreferences))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 200) {
|
||
this.plugin.settings.structuredMemoryConfig.maxPreferences = parsed;
|
||
this.plugin.structuredMemoryManager!.updateConfig(
|
||
this.plugin.settings.structuredMemoryConfig
|
||
);
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max preferences must be between 0 and 200.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Learned Facts')
|
||
.setDesc('Maximum number of learned facts to retain (default: 50).')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxFacts))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 500) {
|
||
this.plugin.settings.structuredMemoryConfig.maxFacts = parsed;
|
||
this.plugin.structuredMemoryManager!.updateConfig(
|
||
this.plugin.settings.structuredMemoryConfig
|
||
);
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max facts must be between 0 and 500.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Clear Structured Memory')
|
||
.setDesc('Delete all stored conversation summaries, preferences, and facts.')
|
||
.addButton((button) =>
|
||
button.setButtonText('Clear Memory').onClick(async () => {
|
||
this.plugin.structuredMemoryManager!.clearAll();
|
||
await this.plugin.saveSettings();
|
||
new Notice('Structured memory cleared.');
|
||
})
|
||
);
|
||
|
||
// Tool Telemetry Settings
|
||
containerEl.createEl('h3', { text: 'Tool Telemetry' });
|
||
containerEl.createEl('p', {
|
||
text: 'Track which tools were called, which notes were searched, and LLM token usage.',
|
||
});
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Tool Telemetry')
|
||
.setDesc('Record tool calls, searches, and LLM token counts for analysis.')
|
||
.addToggle((toggle) =>
|
||
toggle
|
||
.setValue(this.plugin.settings.toolTelemetryConfig.enabled)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.toolTelemetryConfig.enabled = value;
|
||
this.plugin.telemetryManager?.updateConfig(this.plugin.settings.toolTelemetryConfig);
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Telemetry Entries')
|
||
.setDesc('Maximum number of telemetry events to retain (default: 100).')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.toolTelemetryConfig.maxEntries))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1000) {
|
||
this.plugin.settings.toolTelemetryConfig.maxEntries = parsed;
|
||
this.plugin.telemetryManager?.updateConfig(this.plugin.settings.toolTelemetryConfig);
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max entries must be between 0 and 1000.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Clear Tool Telemetry')
|
||
.setDesc('Delete all recorded tool telemetry.')
|
||
.addButton((button) =>
|
||
button.setButtonText('Clear Telemetry').onClick(async () => {
|
||
this.plugin.telemetryManager?.clear();
|
||
await this.plugin.saveSettings();
|
||
new Notice('Tool telemetry cleared.');
|
||
})
|
||
);
|
||
|
||
// Recent Telemetry Summary
|
||
const recentEntries = this.plugin.telemetryManager?.getRecentEntries(10) ?? [];
|
||
if (recentEntries.length > 0) {
|
||
containerEl.createEl('h4', { text: 'Recent Activity' });
|
||
const telemetryList = containerEl.createEl('ul');
|
||
for (const entry of recentEntries) {
|
||
const li = telemetryList.createEl('li');
|
||
if (entry.type === 'tool_call') {
|
||
li.setText(
|
||
`${new Date(entry.timestamp).toLocaleString()}: Tool "${entry.toolName}" — ${entry.success ? 'success' : 'failed'} (${entry.durationMs}ms)`
|
||
);
|
||
} else if (entry.type === 'llm_call') {
|
||
li.setText(
|
||
`${new Date(entry.timestamp).toLocaleString()}: LLM call — ${entry.totalTokens} tokens (${entry.durationMs}ms)`
|
||
);
|
||
} else if (entry.type === 'vault_search') {
|
||
li.setText(
|
||
`${new Date(entry.timestamp).toLocaleString()}: Search "${entry.query}" — ${entry.resultsCount} results`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
hide() {
|
||
// Clear the container to prevent duplicate elements
|
||
this.containerEl.empty();
|
||
}
|
||
}
|