obsidian-hermes-plugin/src/context-bridge.ts
Helm 16fb2238dd feat: context capture fix, multi-tab support, slash commands with autocomplete
- 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.
2026-07-10 11:40:40 -04:00

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;
}
}