- Fix context capture timing bug: snapshot active note on chat focus instead of at send time (when activeEditor is already null). Added workspace active-leaf-change listener to keep context fresh. - Multi-tab: each ChatView now gets its own ApiClient instance, enabling independent sessions per tab. Added 'Open in New Tab' command. Removed shared ApiClient from plugin. - Slash commands: three built-in commands (/prep-session, /sync-content, /lint-wiki) plus JSON-configurable custom commands in settings. On '/' trigger, shows suggestion dropdown with arrow-key navigation, click-to-select, and Escape to close. - Slash command resolution at send time replaces /trigger with full prompt text.
134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
import { App, MarkdownView } from "obsidian";
|
|
|
|
/**
|
|
* Reads the active Obsidian note and produces structured context for Hermes.
|
|
*
|
|
* Strategy: instead of embedding full note content (wastes context tokens),
|
|
* we send a vault-relative path reference. Hermes reads the file on-demand
|
|
* via mcp_obsidian_get_vault_document — zero context burned, same result.
|
|
*
|
|
* If the user has text selected, we embed that selection directly (it's small
|
|
* and intentional). Otherwise we send a path reference.
|
|
*/
|
|
export class ContextBridge {
|
|
private app: App;
|
|
|
|
constructor(app: App) {
|
|
this.app = app;
|
|
}
|
|
|
|
/**
|
|
* Get the active note as a vault-relative reference.
|
|
* Returns null if no markdown editor is active.
|
|
*/
|
|
getActiveNoteContext(): {
|
|
vault: string;
|
|
path: string;
|
|
title: string;
|
|
} | null {
|
|
const activeView = this.app.workspace.activeEditor;
|
|
if (!activeView || !activeView.editor) return null;
|
|
|
|
// Verify it's a Markdown file (not canvas, PDF, etc.)
|
|
const leaf = this.app.workspace.activeLeaf;
|
|
if (!leaf || !(leaf.view instanceof MarkdownView)) return null;
|
|
|
|
const file = leaf.view.file;
|
|
if (!file) return null;
|
|
|
|
return {
|
|
vault: this.app.vault.getName(),
|
|
path: file.path,
|
|
title: file.basename,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Snapshot the active note context AND selection in one atomic read.
|
|
* Call this when the chat view gains focus — captures the note state
|
|
* before the user's focus move clears activeEditor / deselects text.
|
|
*
|
|
* Returns null if no markdown editor is active.
|
|
*/
|
|
snapshot(): {
|
|
vault: string;
|
|
path: string;
|
|
title: string;
|
|
selection: string | null;
|
|
} | null {
|
|
const noteRef = this.getActiveNoteContext();
|
|
if (!noteRef) return null;
|
|
return {
|
|
...noteRef,
|
|
selection: this.getSelectionContext(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the currently selected text in the active editor.
|
|
* Returns null if no text is selected.
|
|
*/
|
|
getSelectionContext(): string | null {
|
|
const activeView = this.app.workspace.activeEditor;
|
|
if (!activeView || !activeView.editor) return null;
|
|
|
|
const selection = activeView.editor.getSelection();
|
|
return selection || null;
|
|
}
|
|
|
|
/**
|
|
* Format a note reference as a system message that tells Hermes
|
|
* which MCP tool call to use to read the file on Helm.
|
|
*/
|
|
formatContextMessage(ref: {
|
|
vault: string;
|
|
path: string;
|
|
title: string;
|
|
}): { role: string; content: string } {
|
|
return {
|
|
role: "system",
|
|
content: [
|
|
`[Current note: ${ref.vault}/${ref.path}]`,
|
|
`Use mcp_obsidian_get_vault_document(vault_name="${ref.vault}", doc_path="${ref.path}") to read the full contents.`,
|
|
].join("\n"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the messages array for an API call.
|
|
*
|
|
* - If text is selected in the active note: embed it directly as context.
|
|
* - Otherwise if context injection is enabled: send a path reference
|
|
* so Hermes can read the file via MCP on Helm.
|
|
* - Always appends the user's chat message.
|
|
*/
|
|
buildMessages(
|
|
userInput: string,
|
|
includeContext: boolean
|
|
): { role: string; content: string }[] {
|
|
const messages: { role: string; content: string }[] = [];
|
|
|
|
if (includeContext) {
|
|
// Prefer selected text (small, intentional, works anywhere)
|
|
const selection = this.getSelectionContext();
|
|
if (selection) {
|
|
const ref = this.getActiveNoteContext();
|
|
const source = ref ? `${ref.vault}/${ref.path}` : "current note";
|
|
messages.push({
|
|
role: "system",
|
|
content: `[Selection from ${source}]\n\n${selection}`,
|
|
});
|
|
} else {
|
|
// No selection — send path reference for Hermes to read via MCP
|
|
const ref = this.getActiveNoteContext();
|
|
if (ref) {
|
|
messages.push(this.formatContextMessage(ref));
|
|
}
|
|
}
|
|
}
|
|
|
|
messages.push({ role: "user", content: userInput });
|
|
|
|
return messages;
|
|
}
|
|
}
|