Initial commit: Obsidian Hermes Chat plugin

- Streaming chat sidebar with OpenAI-compatible API client
- Path-referencing context injection via MCP tools
- Dark theme styling with Obsidian CSS variables
- Settings: gateway URL, API key, context/model toggles
- SSE streaming with fetch timeout protection
- Session continuity via X-Hermes-Session-Id headers
This commit is contained in:
Helm 2026-07-10 11:18:23 -04:00
commit 3b35f59ec8
14 changed files with 1894 additions and 0 deletions

115
README.md Normal file
View file

@ -0,0 +1,115 @@
# Hermes Chat — Obsidian Plugin
Chat with Hermes Agent directly from Obsidian via REST API.
## Prerequisites
- **Obsidian** 1.7.2 or later
- **Hermes Gateway** with the API Server adapter enabled and running
## Installation
1. Build the plugin:
```bash
npm install
npm run build
```
2. Copy the plugin folder into your Obsidian vault:
```
<vault>/.obsidian/plugins/obsidian-hermes-plugin/
```
Required files in the plugin folder:
- `main.js` (built output)
- `manifest.json`
- `styles.css` (dark theme styling)
3. Restart Obsidian or reload plugins:
- **Settings → Community plugins →** enable **Hermes Chat**
## Settings
After enabling the plugin, open **Settings → Hermes Chat**:
| Setting | Description | Default |
|---|---|---|
| **Gateway URL** | Base URL of your Hermes Gateway API server | `http://192.168.1.12:8642/v1` |
| **API Key** | API key for authenticating with the Hermes Gateway | (pre-filled) |
| **Show model info** | Display the current model name in the status bar | Enabled |
| **Enable context injection** | Automatically send the active note as context | Enabled |
## Usage
### Opening the Chat
- **Ribbon icon:** Click the message-square icon in the left ribbon
- **Keyboard:** `Ctrl+Shift+H` (customizable in **Settings → Hotkeys** → "Open Hermes Chat")
- **Command palette:** `Ctrl+P` → "Open Hermes Chat"
The chat opens as a sidebar panel on the right. You can resize it by dragging the edge.
### Sending Messages
Type in the input area at the bottom and press **Enter** to send. Press **Shift+Enter** for a newline. Responses stream in token-by-token — you'll see Hermes's reply appear in real time.
The status bar at the bottom of the chat shows the current model and session ID.
### Context Injection (Sharing Notes with Hermes)
Instead of sending your entire note content with every message (burning context tokens), the plugin sends a **vault-relative path reference**. Hermes reads the file on-demand from Helm using `mcp_obsidian_get_vault_document` — zero context wasted, same result.
This works seamlessly when you edit your vault via SMB from LPC. Hermes and Obsidian both see the same files at the same paths.
There are two ways to trigger context injection:
#### Automatic (recommended)
Enable **"Enable context injection"** in plugin settings and keep your active note open. When you send a message, the plugin prepends a system message like:
```
[Current note: obsidian-skt-revised/01 - Sessions/Session 67.md]
Use mcp_obsidian_get_vault_document(vault_name="obsidian-skt-revised", doc_path="01 - Sessions/Session 67.md") to read the full contents.
```
Hermes then reads the file on Helm and responds with full awareness of your note — no copy-paste, no wasted tokens.
**Toggle it off** when you want a clean chat without note context.
#### Manual (one-shot)
Use **"Send Current Note as Context"** from the command palette (`Ctrl+P`). This opens the chat sidebar with context queued for your next message.
#### Selection-only (embedded)
If you select text before sending, the plugin embeds your selection directly instead of sending a path reference. This is useful for quick questions about a specific passage — the selection is small and intentional, so embedding it costs few tokens.
### Session Continuity
The plugin maintains a persistent session with Hermes. Your conversation history is preserved across messages — Hermes remembers what you discussed earlier in the session, just like the CLI. The session ID is visible in the status bar.
### Status Bar
| Message | Meaning |
|---|---|
| `Hermes: connected` | Gateway is reachable and healthy |
| `Hermes: unreachable` | Gateway is down or unreachable — check that the API server is running on Helm |
| `Hermes: checking...` | Plugin is verifying connectivity on startup |
| `Model: hermes-agent \| Session: api-abc123` | Shown in the chat sidebar footer — current model and session |
## Settings
| Setting | Description | Default |
|---|---|---|
| **Gateway URL** | Base URL of your Hermes Gateway API server | `http://192.168.1.12:8642/v1` |
| **API Key** | API key for authenticating with the Hermes Gateway | (pre-configured) |
| **Show model info** | Display the current model name in the status bar | Enabled |
| **Enable context injection** | Automatically send the active note as context with each message | Enabled |
## Development
```bash
npm install
npm run dev # watch mode — rebuilds on changes
npm run build # production build (minified, no sourcemap)
```

25
esbuild.config.mjs Normal file
View file

@ -0,0 +1,25 @@
import esbuild from "esbuild";
import process from "process";
const prod = process.argv.includes("production");
const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
platform: "node",
external: ["obsidian"],
outfile: "main.js",
sourcemap: prod ? false : "inline",
minify: prod,
treeShaking: true,
logLevel: "info",
});
if (prod) {
await context.rebuild();
console.log("Production build complete.");
await context.dispose();
} else {
await context.watch();
console.log("Watching for changes...");
}

8
main.js Normal file

File diff suppressed because one or more lines are too long

8
manifest.json Normal file
View file

@ -0,0 +1,8 @@
{
"id": "hermes-chat",
"name": "Hermes Chat",
"version": "1.0.0",
"minAppVersion": "1.7.2",
"author": "Lukas Parsons",
"description": "Chat with Hermes Agent directly from Obsidian via REST API"
}

606
package-lock.json generated Normal file
View file

@ -0,0 +1,606 @@
{
"name": "obsidian-hermes-plugin",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-hermes-plugin",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^20.14.0",
"esbuild": "^0.23.0",
"obsidian": "latest",
"typescript": "~5.4.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/codemirror": {
"version": "5.60.8",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/esbuild": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.23.1",
"@esbuild/android-arm": "0.23.1",
"@esbuild/android-arm64": "0.23.1",
"@esbuild/android-x64": "0.23.1",
"@esbuild/darwin-arm64": "0.23.1",
"@esbuild/darwin-x64": "0.23.1",
"@esbuild/freebsd-arm64": "0.23.1",
"@esbuild/freebsd-x64": "0.23.1",
"@esbuild/linux-arm": "0.23.1",
"@esbuild/linux-arm64": "0.23.1",
"@esbuild/linux-ia32": "0.23.1",
"@esbuild/linux-loong64": "0.23.1",
"@esbuild/linux-mips64el": "0.23.1",
"@esbuild/linux-ppc64": "0.23.1",
"@esbuild/linux-riscv64": "0.23.1",
"@esbuild/linux-s390x": "0.23.1",
"@esbuild/linux-x64": "0.23.1",
"@esbuild/netbsd-x64": "0.23.1",
"@esbuild/openbsd-arm64": "0.23.1",
"@esbuild/openbsd-x64": "0.23.1",
"@esbuild/sunos-x64": "0.23.1",
"@esbuild/win32-arm64": "0.23.1",
"@esbuild/win32-ia32": "0.23.1",
"@esbuild/win32-x64": "0.23.1"
}
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/obsidian": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz",
"integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT",
"peer": true
}
}
}

16
package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "obsidian-hermes-plugin",
"version": "1.0.0",
"description": "Chat with Hermes Agent directly from Obsidian via REST API",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production"
},
"devDependencies": {
"@types/node": "^20.14.0",
"esbuild": "^0.23.0",
"obsidian": "latest",
"typescript": "~5.4.0"
}
}

378
src/api-client.ts Normal file
View file

@ -0,0 +1,378 @@
import type { GatewayConfig, ChatCompletionResponse } from "./types";
/**
* HTTP client for the Hermes Gateway's OpenAI-compatible REST API.
* Handles Bearer auth, SSE streaming, session header continuity,
* and model/capabilities discovery.
*
* Uses the standard fetch() API available in Obsidian's Electron runtime.
*/
export class ApiClient {
private baseUrl: string;
private apiKey: string;
private model: string;
private currentSessionId: string | null;
constructor(config: GatewayConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, ""); // strip trailing slashes
this.apiKey = config.apiKey;
this.model = config.model;
this.currentSessionId = null;
}
/** Update configuration without recreating the client. Preserves session. */
updateConfig(config: Partial<GatewayConfig>): void {
if (config.baseUrl !== undefined) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
}
if (config.apiKey !== undefined) {
this.apiKey = config.apiKey;
}
if (config.model !== undefined) {
this.model = config.model;
}
// Do NOT reset currentSessionId — preserve conversation continuity
}
/** Get the current session ID, if one has been established. */
getSessionId(): string | null {
return this.currentSessionId;
}
/** The root URL without the /v1 suffix, e.g. http://192.168.1.12:8642 */
private get rootUrl(): string {
// baseUrl ends with /v1; strip it to reach the server root
if (this.baseUrl.endsWith("/v1")) {
return this.baseUrl.slice(0, -3);
}
return new URL("/", this.baseUrl).href.replace(/\/+$/, "");
}
/**
* Fetch with a configurable timeout. Aborts the request if it takes
* longer than `timeoutMs` milliseconds.
*/
private async fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs: number = 60000
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} finally {
clearTimeout(timeout);
}
}
/**
* Extract a useful error message from a non-OK response.
* Tries JSON first, falls back to plain text (truncated), and finally
* to the HTTP status line.
*/
private async extractErrorBody(response: Response): Promise<string> {
let detail = "";
const contentType = response.headers.get("content-type") ?? "";
// Only attempt JSON if the server says it sent JSON
if (contentType.includes("application/json")) {
try {
const errBody = await response.json();
if (errBody.error?.message) {
detail = errBody.error.message;
} else if (typeof errBody.message === "string") {
detail = errBody.message;
} else if (typeof errBody.error === "string") {
detail = errBody.error;
}
if (detail) return detail;
} catch {
// JSON parsing failed — fall through to text
}
}
// Try reading as plain text (e.g. HTML error page, gateway nginx page)
try {
const text = await response.text();
if (text.trim()) {
// Truncate to avoid dumping huge HTML pages into error messages
detail = text.trim().substring(0, 300);
return detail;
}
} catch {
// Body isn't readable at all
}
// Ultimate fallback
return response.statusText || `HTTP ${response.status}`;
}
/**
* Send a chat completion request (non-streaming).
* Returns the assistant's response with session and usage metadata.
*/
async chat(
messages: { role: string; content: string }[],
stream: boolean = false
): Promise<ChatCompletionResponse> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
if (this.currentSessionId) {
headers["X-Hermes-Session-Id"] = this.currentSessionId;
}
const response = await this.fetchWithTimeout(
`${this.baseUrl}/chat/completions`,
{
method: "POST",
headers,
body: JSON.stringify({
model: this.model,
messages,
stream,
}),
}
);
if (!response.ok) {
const detail = await this.extractErrorBody(response);
throw new Error(`API error ${response.status}: ${detail}`);
}
// Capture session ID from response headers for continuity
const sessionId = response.headers.get("X-Hermes-Session-Id");
if (sessionId) {
this.currentSessionId = sessionId;
}
const data = await response.json();
if (data.error) {
throw new Error(data.error.message || "Unknown API error");
}
const choice = data.choices?.[0];
const content: string = choice?.message?.content ?? "";
return {
content,
sessionId: this.currentSessionId ?? "",
model: data.model ?? this.model,
usage: data.usage,
};
}
/**
* Send a streaming chat completion request.
* Calls `onDelta` with each text chunk as it arrives via SSE.
* Returns the final ChatCompletionResponse after the stream concludes.
*/
async streamChat(
messages: { role: string; content: string }[],
onDelta: (text: string) => void
): Promise<ChatCompletionResponse> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
if (this.currentSessionId) {
headers["X-Hermes-Session-Id"] = this.currentSessionId;
}
const response = await this.fetchWithTimeout(
`${this.baseUrl}/chat/completions`,
{
method: "POST",
headers,
body: JSON.stringify({
model: this.model,
messages,
stream: true,
}),
}
);
if (!response.ok) {
const detail = await this.extractErrorBody(response);
throw new Error(`API error ${response.status}: ${detail}`);
}
// Capture session ID from response headers
const sessionId = response.headers.get("X-Hermes-Session-Id");
if (sessionId) {
this.currentSessionId = sessionId;
}
// Read the SSE stream
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder("utf-8");
let buffer = "";
let fullContent = "";
let model = this.model;
let usage: ChatCompletionResponse["usage"];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE events are separated by double newlines
const parts = buffer.split("\n\n");
// The last part may be incomplete; keep it in the buffer
buffer = parts.pop() ?? "";
for (const part of parts) {
const lines = part.split("\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const jsonStr = line.slice(6).trim();
if (jsonStr === "[DONE]") {
// Stream finished signal — continue processing any remaining data
continue;
}
try {
const event = JSON.parse(jsonStr);
if (event.error) {
throw new Error(event.error.message || "Stream error");
}
const delta = event.choices?.[0]?.delta?.content;
if (delta) {
fullContent += delta;
onDelta(delta);
}
// Capture model and usage from the final event
if (event.model) {
model = event.model;
}
if (event.usage) {
usage = event.usage;
}
} catch (e) {
// If it's our own thrown error, rethrow
if (
e instanceof Error &&
e.message !== "Unexpected token" &&
!e.message.includes("JSON")
) {
throw e;
}
// Otherwise skip malformed SSE lines
}
}
}
}
// Flush any remaining data in the buffer
if (buffer.trim()) {
const lines = buffer.split("\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const jsonStr = line.slice(6).trim();
if (jsonStr === "[DONE]") continue;
try {
const event = JSON.parse(jsonStr);
const delta = event.choices?.[0]?.delta?.content;
if (delta) {
fullContent += delta;
onDelta(delta);
}
} catch {
// ignore
}
}
}
return {
content: fullContent,
sessionId: this.currentSessionId ?? "",
model,
usage,
};
}
/**
* List available models from the gateway.
* GET /v1/models
*/
async getModels(): Promise<{ id: string }[]> {
const response = await this.fetchWithTimeout(
`${this.baseUrl}/models`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
}
);
if (!response.ok) {
const detail = await this.extractErrorBody(response);
throw new Error(`Failed to fetch models: ${response.status}${detail}`);
}
const data = await response.json();
return data.data ?? [];
}
/**
* Fetch gateway capabilities.
* GET /v1/capabilities
*/
async getCapabilities(): Promise<any> {
const response = await this.fetchWithTimeout(
`${this.baseUrl}/capabilities`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
}
);
if (!response.ok) {
const detail = await this.extractErrorBody(response);
throw new Error(
`Failed to fetch capabilities: ${response.status}${detail}`
);
}
return response.json();
}
/**
* Health check against the gateway root.
* GET /health returns true if status === "ok"
* Uses a shorter 10-second timeout since this is a liveness probe.
*/
async healthCheck(): Promise<boolean> {
try {
const response = await this.fetchWithTimeout(
`${this.rootUrl}/health`,
{},
10000 // 10-second timeout for health checks
);
if (!response.ok) return false;
const data = await response.json();
return data.status === "ok";
} catch {
return false;
}
}
}

227
src/chat-view.ts Normal file
View file

@ -0,0 +1,227 @@
import { ItemView, WorkspaceLeaf } from "obsidian";
import type { Message, ChatState, ChatCompletionResponse } from "./types";
import { ContextBridge } from "./context-bridge";
// ApiClient interface — the concrete implementation (api-client.ts) is
// instantiated with GatewayConfig and passed to ChatView via setApiClient().
export interface ApiClient {
chat(
messages: { role: string; content: string }[],
stream?: boolean
): Promise<ChatCompletionResponse>;
streamChat(
messages: { role: string; content: string }[],
onDelta: (text: string) => void
): Promise<ChatCompletionResponse>;
}
export const VIEW_TYPE = "hermes-chat-view";
export class ChatView extends ItemView {
private apiClient: ApiClient | null = null;
private contextBridge: ContextBridge | null = null;
private chatState: ChatState;
private messagesContainer: HTMLElement | null = null;
private statusBar: HTMLElement | null = null;
private loadingEl: HTMLElement | null = null;
private textarea: HTMLTextAreaElement | null = null;
private sendButton: HTMLButtonElement | null = null;
constructor(leaf: WorkspaceLeaf, apiClient?: ApiClient, contextBridge?: ContextBridge) {
super(leaf);
this.apiClient = apiClient ?? null;
this.contextBridge = contextBridge ?? null;
this.chatState = {
sessionId: null,
messages: [],
isLoading: false,
model: "hermes-agent",
contextActive: false,
};
}
getViewType(): string {
return VIEW_TYPE;
}
getDisplayText(): string {
return "Hermes";
}
async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.empty();
container.addClass("hermes-chat-container");
// --- Message list area ---
this.messagesContainer = container.createDiv("chat-messages");
this.messagesContainer.id = "chat-messages";
// Loading indicator (hidden by default)
this.loadingEl = this.messagesContainer.createDiv("chat-loading");
this.loadingEl.setText("Thinking\u2026");
this.loadingEl.style.display = "none";
// --- Input area ---
const inputArea = container.createDiv("chat-input-area");
this.textarea = inputArea.createEl("textarea");
this.textarea.placeholder = "Type your message\u2026";
this.textarea.setAttribute("rows", "1");
this.textarea.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this.handleSend();
}
});
this.sendButton = inputArea.createEl("button");
this.sendButton.setText("Send");
this.sendButton.addEventListener("click", () => this.handleSend());
// --- Status bar ---
this.statusBar = container.createDiv("chat-status-bar");
this.updateStatus(this.chatState.model, null);
}
async onClose(): Promise<void> {
// No persistent cleanup needed
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Set the API client for real streaming calls (called by main.ts). */
setApiClient(client: ApiClient): void {
this.apiClient = client;
}
/** Toggle whether context (e.g. note content) should be sent with requests. */
setContextActive(active: boolean): void {
this.chatState.contextActive = active;
}
/** Append a message bubble to the chat display and scroll to bottom. */
appendMessage(role: "user" | "assistant" | "system", content: string): void {
if (!this.messagesContainer) return;
const wrapper = this.messagesContainer.createDiv("chat-message");
wrapper.addClass(`chat-message-${role}`);
const roleLabel = wrapper.createDiv("chat-message-role");
roleLabel.setText(
role === "user" ? "You" : role === "assistant" ? "Hermes" : "System"
);
const contentEl = wrapper.createDiv("chat-message-content");
contentEl.setText(content);
this.scrollToBottom();
}
/** Show or hide the "Thinking\u2026" loading indicator and lock input. */
setLoading(isLoading: boolean): void {
this.chatState.isLoading = isLoading;
if (this.loadingEl) {
this.loadingEl.style.display = isLoading ? "block" : "none";
}
if (this.textarea) {
this.textarea.disabled = isLoading;
}
if (this.sendButton) {
this.sendButton.disabled = isLoading;
}
}
/** Update the bottom status bar with current model and session info. */
updateStatus(model: string, sessionId: string | null): void {
this.chatState.model = model;
this.chatState.sessionId = sessionId;
if (this.statusBar) {
const sessionPart = sessionId ? `Session: ${sessionId}` : "Session: \u2014";
this.statusBar.setText(`Model: ${model} | ${sessionPart}`);
}
}
/** Clear all messages from the display. */
clearChat(): void {
if (!this.messagesContainer) return;
const children = Array.from(this.messagesContainer.children);
for (const child of children) {
if (child !== this.loadingEl) {
child.remove();
}
}
this.chatState.messages = [];
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
private scrollToBottom(): void {
if (this.messagesContainer) {
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
}
}
private async handleSend(): Promise<void> {
if (!this.textarea) return;
const userInput = this.textarea.value.trim();
if (!userInput) return;
this.textarea.value = "";
// Display user message
this.appendMessage("user", userInput);
// Check API client availability
if (!this.apiClient) {
this.appendMessage(
"assistant",
"[API client not configured. Check plugin settings.]"
);
return;
}
this.setLoading(true);
// Create a placeholder div for the streaming assistant response
const assistantMsgDiv = this.messagesContainer?.createDiv(
"chat-message chat-message-assistant"
);
const roleLabel = assistantMsgDiv?.createDiv("chat-message-role");
if (roleLabel) roleLabel.setText("Hermes");
const contentDiv = assistantMsgDiv?.createDiv("chat-message-content");
if (contentDiv) contentDiv.setText("");
let fullContent = "";
try {
// Build messages with optional note context
const messages = this.contextBridge
? this.contextBridge.buildMessages(userInput, this.chatState.contextActive)
: [{ role: "user", content: userInput }];
const response = await this.apiClient!.streamChat(
messages,
(delta: string) => {
fullContent += delta;
if (contentDiv) contentDiv.setText(fullContent);
this.scrollToBottom();
}
);
if (response.sessionId) {
this.chatState.sessionId = response.sessionId;
this.updateStatus(response.model, response.sessionId);
}
} catch (error: any) {
if (contentDiv) {
contentDiv.setText(`Error: ${error.message || "Unknown error"}`);
}
} finally {
this.setLoading(false);
}
}
}

113
src/context-bridge.ts Normal file
View file

@ -0,0 +1,113 @@
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,
};
}
/**
* 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;
}
}

127
src/main.ts Normal file
View file

@ -0,0 +1,127 @@
import { Plugin, WorkspaceLeaf } from "obsidian";
import { HermesChatSettingTab, HermesChatSettings, DEFAULT_SETTINGS } from "./settings";
import { ChatView, VIEW_TYPE } from "./chat-view";
import { ApiClient } from "./api-client";
import { ContextBridge } from "./context-bridge";
import type { GatewayConfig } from "./types";
export default class HermesChatPlugin extends Plugin {
settings: HermesChatSettings;
private statusBarItem: HTMLElement | null = null;
private apiClient: ApiClient | null = null;
private contextBridge: ContextBridge | null = null;
async onload() {
await this.loadSettings();
// Register the chat view
this.registerView(
VIEW_TYPE,
(leaf: WorkspaceLeaf) => {
const view = new ChatView(leaf, this.apiClient!, this.contextBridge!);
view.setContextActive(this.settings.enableContextInjection);
return view;
}
);
// Add settings tab
this.addSettingTab(new HermesChatSettingTab(this.app, this));
// Ribbon icon to activate chat view
this.addRibbonIcon("message-square", "Open Hermes Chat", () => {
this.activateView();
});
// Status bar item showing model name
if (this.settings.showModelInfo) {
this.statusBarItem = this.addStatusBarItem();
this.statusBarItem.setText("Hermes: connecting...");
}
// Initialize API client from settings
const gatewayConfig: GatewayConfig = {
baseUrl: this.settings.gatewayUrl,
apiKey: this.settings.apiKey,
model: "hermes-agent",
};
this.apiClient = new ApiClient(gatewayConfig);
this.contextBridge = new ContextBridge(this.app);
// Command: Open Hermes Chat
this.addCommand({
id: "open-hermes-chat",
name: "Open Hermes Chat",
callback: () => {
this.activateView();
},
});
// Command: Send Current Note as Context
this.addCommand({
id: "send-note-as-context",
name: "Send Current Note as Context",
callback: () => {
const context = this.contextBridge?.getActiveNoteContext();
if (context) {
this.activateView();
// Note: actual context injection happens in chat-view's handleSend via the contextBridge
console.log(`Context ready: vault=${context.vault}, path=${context.path}`);
} else {
console.log("No active note to send as context");
}
},
});
// Check gateway connectivity
if (this.statusBarItem && this.apiClient) {
this.statusBarItem.setText("Hermes: checking...");
this.apiClient.healthCheck().then((healthy) => {
if (this.statusBarItem) {
this.statusBarItem.setText(healthy ? "Hermes: connected" : "Hermes: unreachable");
}
});
}
}
async onunload() {
// Detach the view when plugin is unloaded
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
async activateView() {
const { workspace } = this.app;
// Check if view already exists
const existing = workspace.getLeavesOfType(VIEW_TYPE);
if (existing.length > 0) {
workspace.revealLeaf(existing[0]);
return;
}
// Create new leaf in right sidebar
const leaf = workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({ type: VIEW_TYPE, active: true });
workspace.revealLeaf(leaf);
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
this.syncContextToViews();
}
/** Push enableContextInjection setting to any open ChatViews. */
private syncContextToViews(): void {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE);
for (const leaf of leaves) {
if (leaf.view instanceof ChatView) {
leaf.view.setContextActive(this.settings.enableContextInjection);
}
}
}
}

83
src/settings.ts Normal file
View file

@ -0,0 +1,83 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import type HermesChatPlugin from "./main";
export interface HermesChatSettings {
gatewayUrl: string;
apiKey: string;
showModelInfo: boolean;
enableContextInjection: boolean;
}
export const DEFAULT_SETTINGS: HermesChatSettings = {
gatewayUrl: "http://192.168.1.12:8642/v1",
apiKey: "0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b",
showModelInfo: true,
enableContextInjection: true,
};
export class HermesChatSettingTab extends PluginSettingTab {
plugin: HermesChatPlugin;
constructor(app: App, plugin: HermesChatPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Hermes Chat Settings" });
new Setting(containerEl)
.setName("Gateway URL")
.setDesc("Base URL of your Hermes Gateway API server (e.g. http://192.168.1.12:8642/v1)")
.addText((text) =>
text
.setPlaceholder("http://192.168.1.12:8642/v1")
.setValue(this.plugin.settings.gatewayUrl)
.onChange(async (value) => {
this.plugin.settings.gatewayUrl = value;
await this.plugin.saveData(this.plugin.settings);
})
);
new Setting(containerEl)
.setName("API Key")
.setDesc("API key for authenticating with the Hermes Gateway")
.addText((text) =>
text
.setPlaceholder("Enter your API key")
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveData(this.plugin.settings);
})
);
new Setting(containerEl)
.setName("Show model info")
.setDesc("Display the current model name in the status bar")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showModelInfo)
.onChange(async (value) => {
this.plugin.settings.showModelInfo = value;
await this.plugin.saveData(this.plugin.settings);
})
);
new Setting(containerEl)
.setName("Enable context injection")
.setDesc("Automatically send the active note as context with each message")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.enableContextInjection)
.onChange(async (value) => {
this.plugin.settings.enableContextInjection = value;
await this.plugin.saveData(this.plugin.settings);
})
);
}
}

31
src/types.ts Normal file
View file

@ -0,0 +1,31 @@
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
export interface GatewayConfig {
baseUrl: string; // e.g. http://192.168.1.12:8642/v1
apiKey: string;
model: string; // default: 'hermes-agent'
}
export interface ChatState {
sessionId: string | null;
messages: Message[];
isLoading: boolean;
model: string;
contextActive: boolean;
}
export interface ChatCompletionResponse {
content: string;
sessionId: string;
model: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}

139
styles.css Normal file
View file

@ -0,0 +1,139 @@
/* ==========================================================================
Hermes Chat Dark Theme
Uses Obsidian CSS variables for native theme compatibility.
========================================================================== */
/* --- Container --- */
.hermes-chat-container {
display: flex;
flex-direction: column;
height: 100%;
}
/* --- Message list --- */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.chat-message {
margin-bottom: 1rem;
padding: 0.5rem 1rem;
border-radius: 8px;
line-height: 1.45;
}
.chat-message-user {
background: var(--background-modifier-hover);
text-align: right;
}
.chat-message-assistant {
background: var(--background-secondary);
}
/* --- Message internals --- */
.chat-message-role {
font-size: 0.75rem;
opacity: 0.6;
margin-bottom: 0.25rem;
font-weight: 500;
}
.chat-message-content {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-normal);
}
/* --- Loading indicator --- */
.chat-loading {
opacity: 0.6;
font-style: italic;
padding: 0.5rem 1rem;
color: var(--text-muted);
}
/* --- Input area --- */
.chat-input-area {
display: flex;
padding: 0.5rem;
border-top: 1px solid var(--background-modifier-border);
gap: 0.5rem;
}
.chat-input-area textarea {
flex: 1;
background: var(--background-primary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
resize: none;
min-height: 40px;
max-height: 120px;
padding: 0.5rem;
font-family: inherit;
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s;
}
.chat-input-area textarea:focus {
border-color: var(--interactive-accent);
}
.chat-input-area textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat-input-area button {
flex-shrink: 0;
margin-left: 0;
cursor: pointer;
padding: 0.5rem 1rem;
background: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
transition: background 0.15s;
}
.chat-input-area button:hover {
background: var(--interactive-accent-hover);
}
.chat-input-area button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* --- Status bar --- */
.chat-status-bar {
font-size: 0.7rem;
opacity: 0.5;
padding: 0.25rem 1rem;
border-top: 1px solid var(--background-modifier-border);
color: var(--text-muted);
}
/* --- Scrollbar (thin, dark) --- */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--background-modifier-border);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM"],
"moduleResolution": "bundler",
"strict": true,
"outDir": ".",
"rootDir": "src",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true
},
"include": ["src/**/*.ts"]
}