Fix and improve file read, write handling in Obsidian

Fixes
- Fix to allow khoj to delete content in obsidian write mode
- Do not throw error when no edit blocks in write mode on obsidian
- Limit retries to fix invalid edit blocks in obsidian write mode

Improvements
- Only show 3 recent files as context in obsidian file read, write mode
- Persist open file access mode setting across restarts in obsidian
- Make khoj obsidian keyboard shortcuts toggle voice chat, chat history
- Do not show <SYSTEM> instructions in chat session title on obsidian

Closes #1209
This commit is contained in:
Debanjum
2025-08-20 20:20:12 -07:00
6 changed files with 129 additions and 74 deletions

View File

@@ -1,9 +1,9 @@
import { ItemView, MarkdownRenderer, Scope, WorkspaceLeaf, request, requestUrl, setIcon, Platform, TFile } from 'obsidian';
import * as DOMPurify from 'isomorphic-dompurify';
import { KhojSetting } from 'src/settings';
import { KhojPaneView } from 'src/pane_view';
import { KhojView, createCopyParentText, getLinkToEntry, pasteTextAtCursor } from 'src/utils';
import { KhojSearchModal } from 'src/search_modal';
import Khoj from 'src/main';
import { FileInteractions, EditBlock } from 'src/interact_with_files';
export interface ChatJsonResult {
@@ -67,12 +67,12 @@ interface Agent {
export class KhojChatView extends KhojPaneView {
result: string;
setting: KhojSetting;
waitingForLocation: boolean;
location: Location = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone };
keyPressTimeout: NodeJS.Timeout | null = null;
userMessages: string[] = []; // Store user sent messages for input history cycling
currentMessageIndex: number = -1; // Track current message index in userMessages array
voiceChatActive: boolean = false; // Flag to track if voice chat is active
private currentUserInput: string = ""; // Stores the current user input that is being typed in chat
private startingMessage: string = this.getLearningMoment();
chatMessageState: ChatMessageState;
@@ -101,10 +101,13 @@ export class KhojChatView extends KhojPaneView {
// 2. Higher invalid edit blocks than tolerable
private maxEditRetries: number = 1; // Maximum retries for edit blocks
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf, setting);
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
super(leaf, plugin);
this.fileInteractions = new FileInteractions(this.app);
// Initialize file access mode from persisted settings
this.fileAccessMode = this.setting.fileAccessMode ?? 'read';
this.waitingForLocation = true;
fetch("https://ipapi.co/json")
@@ -129,7 +132,7 @@ export class KhojChatView extends KhojPaneView {
this.scope = new Scope(this.app.scope);
this.scope.register(["Ctrl", "Alt"], 'n', (_) => this.createNewConversation(this.currentAgent));
this.scope.register(["Ctrl", "Alt"], 'o', async (_) => await this.toggleChatSessions());
this.scope.register(["Ctrl", "Alt"], 'v', (_) => this.speechToText(new KeyboardEvent('keydown')));
this.scope.register(["Ctrl", "Alt"], 'v', (_) => this.speechToText(this.voiceChatActive ? new KeyboardEvent('keyup') : new KeyboardEvent('keydown')));
this.scope.register(["Ctrl"], 'f', (_) => new KhojSearchModal(this.app, this.setting).open());
this.scope.register(["Ctrl"], 'r', (_) => { this.activateView(KhojView.SIMILAR); });
}
@@ -272,29 +275,48 @@ export class KhojChatView extends KhojPaneView {
text: "File Access",
attr: {
class: "khoj-input-row-button clickable-icon",
title: "Toggle file access mode (Read Only)",
title: "Toggle open file access",
},
});
setIcon(fileAccessButton, "file-search");
fileAccessButton.addEventListener('click', () => {
// Set initial icon based on persisted setting
switch (this.fileAccessMode) {
case 'none':
setIcon(fileAccessButton, "file-x");
fileAccessButton.title = "Toggle open file access (No Access)";
break;
case 'write':
setIcon(fileAccessButton, "file-edit");
fileAccessButton.title = "Toggle open file access (Read & Write)";
break;
case 'read':
default:
setIcon(fileAccessButton, "file-search");
fileAccessButton.title = "Toggle open file access (Read Only)";
break;
}
fileAccessButton.addEventListener('click', async () => {
// Cycle through modes: none -> read -> write -> none
switch (this.fileAccessMode) {
case 'none':
this.fileAccessMode = 'read';
setIcon(fileAccessButton, "file-search");
fileAccessButton.title = "Toggle file access mode (Read Only)";
fileAccessButton.title = "Toggle open file access (Read Only)";
break;
case 'read':
this.fileAccessMode = 'write';
setIcon(fileAccessButton, "file-edit");
fileAccessButton.title = "Toggle file access mode (Read & Write)";
fileAccessButton.title = "Toggle open file access (Read & Write)";
break;
case 'write':
this.fileAccessMode = 'none';
setIcon(fileAccessButton, "file-x");
fileAccessButton.title = "Toggle file access mode (No Access)";
fileAccessButton.title = "Toggle open file access (No Access)";
break;
}
// Persist the updated mode to settings
this.setting.fileAccessMode = this.fileAccessMode;
await this.plugin.saveSettings();
});
let chatInput = inputRow.createEl("textarea", {
@@ -319,7 +341,7 @@ export class KhojChatView extends KhojPaneView {
attr: {
id: "khoj-transcribe",
class: "khoj-transcribe khoj-input-row-button clickable-icon ",
title: "Start Voice Chat (Ctrl+Alt+V)",
title: "Hold to Voice Chat (Ctrl+Alt+V)",
},
})
transcribe.addEventListener('mousedown', (event) => { this.startSpeechToText(event) });
@@ -1037,7 +1059,7 @@ export class KhojChatView extends KhojPaneView {
if (incomingConversationId == conversationId) {
conversationSessionEl.classList.add("selected-conversation");
}
const conversationTitle = conversation["slug"] || `New conversation 🌱`;
const conversationTitle = conversation["slug"].split("<SYSTEM>")[0].trim() || `New conversation 🌱`;
const conversationSessionTitleEl = conversationSessionEl.createDiv("conversation-session-title");
conversationSessionTitleEl.textContent = conversationTitle;
conversationSessionTitleEl.addEventListener('click', () => {
@@ -1190,7 +1212,7 @@ export class KhojChatView extends KhojPaneView {
chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`;
}
console.log("Fetching chat history from:", chatUrl);
console.debug("Fetching chat history from:", chatUrl);
try {
let response = await fetch(chatUrl, {
@@ -1199,7 +1221,7 @@ export class KhojChatView extends KhojPaneView {
});
let responseJson: any = await response.json();
console.log("Chat history response:", responseJson);
console.debug("Chat history response:", responseJson);
chatBodyEl.dataset.conversationId = responseJson.conversation_id;
@@ -1221,7 +1243,7 @@ export class KhojChatView extends KhojPaneView {
// Update current agent from conversation history
if (responseJson.response.agent?.slug) {
console.log("Found agent in conversation history:", responseJson.response.agent);
console.debug("Found agent in conversation history:", responseJson.response.agent);
this.currentAgent = responseJson.response.agent.slug;
// Update the agent selector if it exists
const agentSelect = this.contentEl.querySelector('.khoj-header-agent-select') as HTMLSelectElement;
@@ -1352,18 +1374,25 @@ export class KhojChatView extends KhojPaneView {
if (this.fileAccessMode === 'write') {
const editBlocks = this.parseEditBlocks(this.chatMessageState.rawResponse);
// Check for errors and retry if needed
if (editBlocks.length > 0 && editBlocks[0].hasError && this.editRetryCount < this.maxEditRetries) {
await this.handleEditRetry(editBlocks[0]);
return;
}
// Reset retry count on success
this.editRetryCount = 0;
// Apply edits if there are any
if (editBlocks.length > 0) {
await this.applyEditBlocks(editBlocks);
const firstBlock = editBlocks[0];
if (firstBlock.hasError) {
// Only retry if we have remaining attempts; do NOT reset counter on failure
if (this.editRetryCount < this.maxEditRetries) {
await this.handleEditRetry(firstBlock);
return; // Wait for retry response
} else {
// Exhausted retries; surface error and do not attempt further automatic retries
console.warn('[Khoj] Max edit retries reached. Aborting further retries.');
}
} else {
// Successful parse => reset counter and apply edits
this.editRetryCount = 0;
await this.applyEditBlocks(editBlocks);
}
} else {
// No edit blocks => reset counter just in case
this.editRetryCount = 0;
}
}
@@ -1720,6 +1749,7 @@ export class KhojChatView extends KhojPaneView {
// Toggle recording
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive' || event.type === 'touchstart' || event.type === 'mousedown' || event.type === 'keydown') {
this.voiceChatActive = true;
navigator.mediaDevices
.getUserMedia({ audio: true })
?.then(handleRecording)
@@ -1727,6 +1757,7 @@ export class KhojChatView extends KhojPaneView {
this.flashStatusInChatInput("⛔️ Failed to access microphone");
});
} else if (this.mediaRecorder?.state === 'recording' || event.type === 'touchend' || event.type === 'touchcancel' || event.type === 'mouseup' || event.type === 'keyup') {
this.voiceChatActive = false;
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
this.mediaRecorder = undefined;
@@ -2403,7 +2434,7 @@ export class KhojChatView extends KhojPaneView {
// Add retry count
retryBadge.createSpan({
cls: "retry-count",
text: `Attempt ${this.editRetryCount}/3`
text: `Attempt ${this.editRetryCount}/${this.maxEditRetries}`
});
// Add error details as a tooltip
@@ -2418,7 +2449,7 @@ export class KhojChatView extends KhojPaneView {
retryBadge.scrollIntoView({ behavior: "smooth", block: "center" });
// Create a retry prompt for the LLM
const retryPrompt = `/general I noticed some issues with the edit block. Please fix the following and provide a corrected version (retry ${this.editRetryCount}/3):\n\n${errorDetails}\n\nPlease provide a new edit block that fixes these issues. Make sure to follow the exact format required.`;
const retryPrompt = `/general I noticed some issues with the edit block. Please fix the following and provide a corrected version (retry ${this.editRetryCount}/${this.maxEditRetries}):\n\n${errorDetails}\n\nPlease provide a new edit block that fixes these issues. Make sure to follow the exact format required.`;
// Send retry request without displaying the user message
await this.getChatResponse(retryPrompt, "", false, false);

View File

@@ -1,4 +1,4 @@
import { App, TFile } from 'obsidian';
import { App, MarkdownView, TFile } from 'obsidian';
import { diffWords } from 'diff';
/**
@@ -55,6 +55,7 @@ export class FileInteractions {
private app: App;
private readonly EDIT_BLOCK_START = '<khoj_edit>';
private readonly EDIT_BLOCK_END = '</khoj_edit>';
private readonly CONTEXT_FILES_LIMIT = 3;
/**
* Constructor for FileInteractions
@@ -65,6 +66,26 @@ export class FileInteractions {
this.app = app;
}
/**
* Get N open, recently viewed markdown files.
*/
private getRecentActiveMarkdownFiles(N: number): TFile[] {
const seen = new Set<string>();
const recentActiveFiles = this.app.workspace.getLeavesOfType('markdown')
.sort((a, b) => (b as any).activeTime - (a as any).activeTime) // Sort by leaf activeTime (note: undocumented prop)
.map(leaf => (leaf.view as MarkdownView)?.file)
// Dedupe by file path
.filter((file): file is TFile => {
if (!file || seen.has(file.path)) return false;
seen.add(file.path);
return true;
})
.slice(0, N);
console.log(`Using ${recentActiveFiles.length} recently viewed md files for context: ${recentActiveFiles.map(file => file.path).join(', ')}`);
return recentActiveFiles;
}
/**
* Gets the content of all open files
*
@@ -75,9 +96,9 @@ export class FileInteractions {
// Only proceed if we have read or write access
if (fileAccessMode === 'none') return '';
// Get all open markdown leaves
const leaves = this.app.workspace.getLeavesOfType('markdown');
if (leaves.length === 0) return '';
// Get recently viewed markdown files
const recentFiles = this.getRecentActiveMarkdownFiles(this.CONTEXT_FILES_LIMIT);
if (recentFiles.length === 0) return '';
// Instructions in write access mode
let editInstructions: string = '';
@@ -274,11 +295,7 @@ For context, the user is currently working on the following files:
`;
for (const leaf of leaves) {
const view = leaf.view as any;
const file = view?.file;
if (!file || file.extension !== 'md') continue;
for (const file of recentFiles) {
// Read file content
let fileContent: string;
try {
@@ -415,8 +432,16 @@ For context, the user is currently working on the following files:
}
// Try parse SEARCH/REPLACE format for complete edit blocks
// Regex: file_path\n<<<<<<< SEARCH\nsearch_content\n=======\nreplacement_content\n>>>>>>> REPLACE
const newFormatRegex = /^([^\n]+)\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE\s*$/;
// Supports empty SEARCH (new file / replace whole file) and empty REPLACE (deletion)
// Regex structure:
// file_path (group 1)
// <<<<<<< SEARCH literal marker
// search_content (group 2, can be empty)
// ======= divider
// replacement_content (group 3, can be empty => deletion)
// >>>>>>> REPLACE end marker
// Note: The trailing newline before the end marker is optional to allow zero-length replacement
const newFormatRegex = /^([^\n]+)\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n?>>>>>>> REPLACE\s*$/;
const newFormatMatch = newFormatRegex.exec(cleanContent);
let editData: EditBlock | null = null;
@@ -430,32 +455,25 @@ For context, the user is currently working on the following files:
// Validate required fields
let error: { type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown', message: string, details?: string } | null = null;
if (!editData) {
error = {
type: 'invalid_format',
message: 'Invalid edit block format',
details: 'The edit block does not match the expected format'
};
}
else if (!editData.file) {
if (editData && !editData.file) {
error = {
type: 'missing_field',
message: 'Missing "file" field in edit block',
details: 'The "file" field is required and should contain the target file name'
};
}
else if (editData.find === undefined || editData.find === null) {
else if (editData && (editData.find === undefined || editData.find === null)) {
error = {
type: 'missing_field',
message: 'Missing "find" field markers',
details: 'The "find" field is required and should contain the content to find in the file'
details: 'The "find" field is required. It should contain the content to find in the file or be empty for new files'
};
}
else if (!editData.replace) {
else if (editData && editData.replace === undefined) {
error = {
type: 'missing_field',
message: 'Missing "replace" field in edit block',
details: 'The "replace" field is required and should contain the replacement text'
details: 'The "replace" field is required. It should contain the content to replace or be empty to indicate deletion'
};
}
@@ -507,7 +525,7 @@ For context, the user is currently working on the following files:
}
if (!editData) {
console.error("No edit data parsed");
console.debug("No edit data parsed");
continue;
}
@@ -684,10 +702,8 @@ For context, the user is currently working on the following files:
// Track current content for each file as we apply edits
const currentFileContents = new Map<string, string>();
// Get all open markdown files
const files = this.app.workspace.getLeavesOfType('markdown')
.map(leaf => (leaf.view as any)?.file)
.filter(file => file && file.extension === 'md');
// Get recently viewed markdown file(s) to edit
const files = this.getRecentActiveMarkdownFiles(this.CONTEXT_FILES_LIMIT);
// Track success/failure for each edit
const editResults: { block: EditBlock, success: boolean, error?: string }[] = [];
@@ -883,6 +899,10 @@ For context, the user is currently working on the following files:
// Parse the block content
const { editData, cleanContent, error, inProgress } = this.parseEditBlock(content, isComplete);
if (!editData && !error) {
// If no edit data and no error, skip this block
continue;
}
// Escape content for HTML display
const diff = diffWords(editData?.find || '', editData?.replace || '');
@@ -898,7 +918,7 @@ For context, the user is currently working on the following files:
).join('').trim();
let htmlRender = '';
if (error || !editData) {
if (error) {
// Error block
console.error("Error parsing khoj-edit block:", error);
console.error("Content causing error:", content);
@@ -913,7 +933,7 @@ For context, the user is currently working on the following files:
<pre><code class="language-md error">${diffContent}</code></pre>
</div>
</details>`;
} else if (inProgress) {
} else if (editData && inProgress) {
// In-progress block
htmlRender = `<details class="khoj-edit-accordion in-progress">
<summary>📄 ${editData.file} <span class="khoj-edit-status">In Progress</span></summary>
@@ -921,7 +941,7 @@ For context, the user is currently working on the following files:
<pre><code class="language-md">${diffContent}</code></pre>
</div>
</details>`;
} else {
} else if (editData) {
// Success block
// Find the actual file that will be modified
const targetFile = this.findBestMatchingFile(editData.file, files);

View File

@@ -3,8 +3,8 @@ import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings'
import { KhojSearchModal } from 'src/search_modal'
import { KhojChatView } from 'src/chat_view'
import { KhojSimilarView } from 'src/similar_view'
import { updateContentIndex, canConnectToBackend, KhojView, jumpToPreviousView } from './utils';
import { KhojPaneView } from './pane_view';
import { updateContentIndex, canConnectToBackend, KhojView } from 'src/utils';
import { KhojPaneView } from 'src/pane_view';
export default class Khoj extends Plugin {
@@ -73,7 +73,7 @@ export default class Khoj extends Plugin {
this.activateView(KhojView.CHAT).then(() => {
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
chatView.toggleChatSessions(true);
chatView.toggleChatSessions();
}
});
}
@@ -88,8 +88,9 @@ export default class Khoj extends Plugin {
this.activateView(KhojView.CHAT).then(() => {
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
// Trigger speech to text functionality
chatView.speechToText(new KeyboardEvent('keydown'));
// Toggle speech to text functionality
const toggleEvent = chatView.voiceChatActive ? 'keyup' : 'keydown';
chatView.speechToText(new KeyboardEvent(toggleEvent));
}
});
}
@@ -136,8 +137,8 @@ export default class Khoj extends Plugin {
});
// Register views
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings));
this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this.settings));
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this));
this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this));
// Create an icon in the left ribbon.
this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => {

View File

@@ -1,14 +1,17 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import { KhojSetting } from 'src/settings';
import { KhojView, populateHeaderPane } from './utils';
import Khoj from 'src/main';
export abstract class KhojPaneView extends ItemView {
setting: KhojSetting;
plugin: Khoj;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
super(leaf);
this.setting = setting;
this.setting = plugin.settings;
this.plugin = plugin;
// Register Modal Keybindings to send user message
// this.scope.register([], 'Enter', async () => { await this.chat() });

View File

@@ -38,6 +38,7 @@ export interface KhojSetting {
syncFolders: string[];
syncInterval: number;
autoVoiceResponse: boolean;
fileAccessMode: 'none' | 'read' | 'write';
selectedChatModelId: string | null; // Mirrors server's selected_chat_model_config
availableChatModels: ModelOption[];
}
@@ -58,6 +59,7 @@ export const DEFAULT_SETTINGS: KhojSetting = {
syncFolders: [],
syncInterval: 60,
autoVoiceResponse: true,
fileAccessMode: 'read',
selectedChatModelId: null, // Will be populated from server
availableChatModels: [],
}

View File

@@ -1,7 +1,7 @@
import { WorkspaceLeaf, TFile, MarkdownRenderer, Notice, setIcon } from 'obsidian';
import { KhojSetting } from 'src/settings';
import { KhojPaneView } from 'src/pane_view';
import { KhojView, getLinkToEntry, supportedBinaryFileTypes } from 'src/utils';
import Khoj from 'src/main';
export interface SimilarResult {
entry: string;
@@ -11,7 +11,6 @@ export interface SimilarResult {
export class KhojSimilarView extends KhojPaneView {
static iconName: string = "search";
setting: KhojSetting;
currentController: AbortController | null = null;
isLoading: boolean = false;
loadingEl: HTMLElement;
@@ -21,9 +20,8 @@ export class KhojSimilarView extends KhojPaneView {
fileWatcher: any;
component: any;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf, setting);
this.setting = setting;
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
super(leaf, plugin);
this.component = this;
}