commit 3b35f59ec80cb386e42c335334a22718abc743ea Author: Helm Date: Fri Jul 10 11:18:23 2026 -0400 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2efb894 --- /dev/null +++ b/README.md @@ -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: + ``` + /.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) +``` diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..bd67e6f --- /dev/null +++ b/esbuild.config.mjs @@ -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..."); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..818cc03 --- /dev/null +++ b/main.js @@ -0,0 +1,8 @@ +"use strict";var S=Object.defineProperty;var H=Object.getOwnPropertyDescriptor;var U=Object.getOwnPropertyNames;var L=Object.prototype.hasOwnProperty;var M=(o,t)=>{for(var e in t)S(o,e,{get:t[e],enumerable:!0})},N=(o,t,e,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of U(t))!L.call(o,i)&&i!==e&&S(o,i,{get:()=>t[i],enumerable:!(s=H(t,i))||s.enumerable});return o};var j=o=>N(S({},"__esModule",{value:!0}),o);var K={};M(K,{default:()=>x});module.exports=j(K);var D=require("obsidian");var h=require("obsidian"),$={gatewayUrl:"http://192.168.1.12:8642/v1",apiKey:"0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b",showModelInfo:!0,enableContextInjection:!0},v=class extends h.PluginSettingTab{plugin;constructor(t,e){super(t,e),this.plugin=e}display(){let{containerEl:t}=this;t.empty(),t.createEl("h2",{text:"Hermes Chat Settings"}),new h.Setting(t).setName("Gateway URL").setDesc("Base URL of your Hermes Gateway API server (e.g. http://192.168.1.12:8642/v1)").addText(e=>e.setPlaceholder("http://192.168.1.12:8642/v1").setValue(this.plugin.settings.gatewayUrl).onChange(async s=>{this.plugin.settings.gatewayUrl=s,await this.plugin.saveData(this.plugin.settings)})),new h.Setting(t).setName("API Key").setDesc("API key for authenticating with the Hermes Gateway").addText(e=>e.setPlaceholder("Enter your API key").setValue(this.plugin.settings.apiKey).onChange(async s=>{this.plugin.settings.apiKey=s,await this.plugin.saveData(this.plugin.settings)})),new h.Setting(t).setName("Show model info").setDesc("Display the current model name in the status bar").addToggle(e=>e.setValue(this.plugin.settings.showModelInfo).onChange(async s=>{this.plugin.settings.showModelInfo=s,await this.plugin.saveData(this.plugin.settings)})),new h.Setting(t).setName("Enable context injection").setDesc("Automatically send the active note as context with each message").addToggle(e=>e.setValue(this.plugin.settings.enableContextInjection).onChange(async s=>{this.plugin.settings.enableContextInjection=s,await this.plugin.saveData(this.plugin.settings)}))}};var k=require("obsidian"),d="hermes-chat-view",f=class extends k.ItemView{apiClient=null;contextBridge=null;chatState;messagesContainer=null;statusBar=null;loadingEl=null;textarea=null;sendButton=null;constructor(t,e,s){super(t),this.apiClient=e??null,this.contextBridge=s??null,this.chatState={sessionId:null,messages:[],isLoading:!1,model:"hermes-agent",contextActive:!1}}getViewType(){return d}getDisplayText(){return"Hermes"}async onOpen(){let t=this.containerEl.children[1];t.empty(),t.addClass("hermes-chat-container"),this.messagesContainer=t.createDiv("chat-messages"),this.messagesContainer.id="chat-messages",this.loadingEl=this.messagesContainer.createDiv("chat-loading"),this.loadingEl.setText("Thinking\u2026"),this.loadingEl.style.display="none";let e=t.createDiv("chat-input-area");this.textarea=e.createEl("textarea"),this.textarea.placeholder="Type your message\u2026",this.textarea.setAttribute("rows","1"),this.textarea.addEventListener("keydown",s=>{s.key==="Enter"&&!s.shiftKey&&(s.preventDefault(),this.handleSend())}),this.sendButton=e.createEl("button"),this.sendButton.setText("Send"),this.sendButton.addEventListener("click",()=>this.handleSend()),this.statusBar=t.createDiv("chat-status-bar"),this.updateStatus(this.chatState.model,null)}async onClose(){}setApiClient(t){this.apiClient=t}setContextActive(t){this.chatState.contextActive=t}appendMessage(t,e){if(!this.messagesContainer)return;let s=this.messagesContainer.createDiv("chat-message");s.addClass(`chat-message-${t}`),s.createDiv("chat-message-role").setText(t==="user"?"You":t==="assistant"?"Hermes":"System"),s.createDiv("chat-message-content").setText(e),this.scrollToBottom()}setLoading(t){this.chatState.isLoading=t,this.loadingEl&&(this.loadingEl.style.display=t?"block":"none"),this.textarea&&(this.textarea.disabled=t),this.sendButton&&(this.sendButton.disabled=t)}updateStatus(t,e){if(this.chatState.model=t,this.chatState.sessionId=e,this.statusBar){let s=e?`Session: ${e}`:"Session: \u2014";this.statusBar.setText(`Model: ${t} | ${s}`)}}clearChat(){if(!this.messagesContainer)return;let t=Array.from(this.messagesContainer.children);for(let e of t)e!==this.loadingEl&&e.remove();this.chatState.messages=[]}scrollToBottom(){this.messagesContainer&&(this.messagesContainer.scrollTop=this.messagesContainer.scrollHeight)}async handleSend(){if(!this.textarea)return;let t=this.textarea.value.trim();if(!t)return;if(this.textarea.value="",this.appendMessage("user",t),!this.apiClient){this.appendMessage("assistant","[API client not configured. Check plugin settings.]");return}this.setLoading(!0);let e=this.messagesContainer?.createDiv("chat-message chat-message-assistant"),s=e?.createDiv("chat-message-role");s&&s.setText("Hermes");let i=e?.createDiv("chat-message-content");i&&i.setText("");let n="";try{let a=this.contextBridge?this.contextBridge.buildMessages(t,this.chatState.contextActive):[{role:"user",content:t}],c=await this.apiClient.streamChat(a,l=>{n+=l,i&&i.setText(n),this.scrollToBottom()});c.sessionId&&(this.chatState.sessionId=c.sessionId,this.updateStatus(c.model,c.sessionId))}catch(a){i&&i.setText(`Error: ${a.message||"Unknown error"}`)}finally{this.setLoading(!1)}}};var C=class{baseUrl;apiKey;model;currentSessionId;constructor(t){this.baseUrl=t.baseUrl.replace(/\/+$/,""),this.apiKey=t.apiKey,this.model=t.model,this.currentSessionId=null}updateConfig(t){t.baseUrl!==void 0&&(this.baseUrl=t.baseUrl.replace(/\/+$/,"")),t.apiKey!==void 0&&(this.apiKey=t.apiKey),t.model!==void 0&&(this.model=t.model)}getSessionId(){return this.currentSessionId}get rootUrl(){return this.baseUrl.endsWith("/v1")?this.baseUrl.slice(0,-3):new URL("/",this.baseUrl).href.replace(/\/+$/,"")}async fetchWithTimeout(t,e,s=6e4){let i=new AbortController,n=setTimeout(()=>i.abort(),s);try{return await fetch(t,{...e,signal:i.signal})}finally{clearTimeout(n)}}async extractErrorBody(t){let e="";if((t.headers.get("content-type")??"").includes("application/json"))try{let i=await t.json();if(i.error?.message?e=i.error.message:typeof i.message=="string"?e=i.message:typeof i.error=="string"&&(e=i.error),e)return e}catch{}try{let i=await t.text();if(i.trim())return e=i.trim().substring(0,300),e}catch{}return t.statusText||`HTTP ${t.status}`}async chat(t,e=!1){let s={Authorization:`Bearer ${this.apiKey}`,"Content-Type":"application/json"};this.currentSessionId&&(s["X-Hermes-Session-Id"]=this.currentSessionId);let i=await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`,{method:"POST",headers:s,body:JSON.stringify({model:this.model,messages:t,stream:e})});if(!i.ok){let p=await this.extractErrorBody(i);throw new Error(`API error ${i.status}: ${p}`)}let n=i.headers.get("X-Hermes-Session-Id");n&&(this.currentSessionId=n);let a=await i.json();if(a.error)throw new Error(a.error.message||"Unknown API error");return{content:a.choices?.[0]?.message?.content??"",sessionId:this.currentSessionId??"",model:a.model??this.model,usage:a.usage}}async streamChat(t,e){let s={Authorization:`Bearer ${this.apiKey}`,"Content-Type":"application/json"};this.currentSessionId&&(s["X-Hermes-Session-Id"]=this.currentSessionId);let i=await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`,{method:"POST",headers:s,body:JSON.stringify({model:this.model,messages:t,stream:!0})});if(!i.ok){let u=await this.extractErrorBody(i);throw new Error(`API error ${i.status}: ${u}`)}let n=i.headers.get("X-Hermes-Session-Id");n&&(this.currentSessionId=n);let a=i.body?.getReader();if(!a)throw new Error("Response body is not readable");let c=new TextDecoder("utf-8"),l="",p="",T=this.model,I;for(;;){let{done:u,value:y}=await a.read();if(u)break;l+=c.decode(y,{stream:!0});let g=l.split(` + +`);l=g.pop()??"";for(let E of g){let m=E.split(` +`);for(let B of m){if(!B.startsWith("data: "))continue;let A=B.slice(6).trim();if(A!=="[DONE]")try{let r=JSON.parse(A);if(r.error)throw new Error(r.error.message||"Stream error");let b=r.choices?.[0]?.delta?.content;b&&(p+=b,e(b)),r.model&&(T=r.model),r.usage&&(I=r.usage)}catch(r){if(r instanceof Error&&r.message!=="Unexpected token"&&!r.message.includes("JSON"))throw r}}}}if(l.trim()){let u=l.split(` +`);for(let y of u){if(!y.startsWith("data: "))continue;let g=y.slice(6).trim();if(g!=="[DONE]")try{let m=JSON.parse(g).choices?.[0]?.delta?.content;m&&(p+=m,e(m))}catch{}}}return{content:p,sessionId:this.currentSessionId??"",model:T,usage:I}}async getModels(){let t=await this.fetchWithTimeout(`${this.baseUrl}/models`,{headers:{Authorization:`Bearer ${this.apiKey}`}});if(!t.ok){let s=await this.extractErrorBody(t);throw new Error(`Failed to fetch models: ${t.status} \u2014 ${s}`)}return(await t.json()).data??[]}async getCapabilities(){let t=await this.fetchWithTimeout(`${this.baseUrl}/capabilities`,{headers:{Authorization:`Bearer ${this.apiKey}`}});if(!t.ok){let e=await this.extractErrorBody(t);throw new Error(`Failed to fetch capabilities: ${t.status} \u2014 ${e}`)}return t.json()}async healthCheck(){try{let t=await this.fetchWithTimeout(`${this.rootUrl}/health`,{},1e4);return t.ok?(await t.json()).status==="ok":!1}catch{return!1}}};var P=require("obsidian"),w=class{app;constructor(t){this.app=t}getActiveNoteContext(){let t=this.app.workspace.activeEditor;if(!t||!t.editor)return null;let e=this.app.workspace.activeLeaf;if(!e||!(e.view instanceof P.MarkdownView))return null;let s=e.view.file;return s?{vault:this.app.vault.getName(),path:s.path,title:s.basename}:null}getSelectionContext(){let t=this.app.workspace.activeEditor;return!t||!t.editor?null:t.editor.getSelection()||null}formatContextMessage(t){return{role:"system",content:[`[Current note: ${t.vault}/${t.path}]`,`Use mcp_obsidian_get_vault_document(vault_name="${t.vault}", doc_path="${t.path}") to read the full contents.`].join(` +`)}}buildMessages(t,e){let s=[];if(e){let i=this.getSelectionContext();if(i){let n=this.getActiveNoteContext(),a=n?`${n.vault}/${n.path}`:"current note";s.push({role:"system",content:`[Selection from ${a}] + +${i}`})}else{let n=this.getActiveNoteContext();n&&s.push(this.formatContextMessage(n))}}return s.push({role:"user",content:t}),s}};var x=class extends D.Plugin{settings;statusBarItem=null;apiClient=null;contextBridge=null;async onload(){await this.loadSettings(),this.registerView(d,e=>{let s=new f(e,this.apiClient,this.contextBridge);return s.setContextActive(this.settings.enableContextInjection),s}),this.addSettingTab(new v(this.app,this)),this.addRibbonIcon("message-square","Open Hermes Chat",()=>{this.activateView()}),this.settings.showModelInfo&&(this.statusBarItem=this.addStatusBarItem(),this.statusBarItem.setText("Hermes: connecting..."));let t={baseUrl:this.settings.gatewayUrl,apiKey:this.settings.apiKey,model:"hermes-agent"};this.apiClient=new C(t),this.contextBridge=new w(this.app),this.addCommand({id:"open-hermes-chat",name:"Open Hermes Chat",callback:()=>{this.activateView()}}),this.addCommand({id:"send-note-as-context",name:"Send Current Note as Context",callback:()=>{let e=this.contextBridge?.getActiveNoteContext();e?(this.activateView(),console.log(`Context ready: vault=${e.vault}, path=${e.path}`)):console.log("No active note to send as context")}}),this.statusBarItem&&this.apiClient&&(this.statusBarItem.setText("Hermes: checking..."),this.apiClient.healthCheck().then(e=>{this.statusBarItem&&this.statusBarItem.setText(e?"Hermes: connected":"Hermes: unreachable")}))}async onunload(){this.app.workspace.detachLeavesOfType(d)}async activateView(){let{workspace:t}=this.app,e=t.getLeavesOfType(d);if(e.length>0){t.revealLeaf(e[0]);return}let s=t.getRightLeaf(!1);s&&(await s.setViewState({type:d,active:!0}),t.revealLeaf(s))}async loadSettings(){this.settings=Object.assign({},$,await this.loadData())}async saveSettings(){await this.saveData(this.settings),this.syncContextToViews()}syncContextToViews(){let t=this.app.workspace.getLeavesOfType(d);for(let e of t)e.view instanceof f&&e.view.setContextActive(this.settings.enableContextInjection)}}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2e0bb43 --- /dev/null +++ b/manifest.json @@ -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" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..50a90b4 --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea2ebc9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api-client.ts b/src/api-client.ts new file mode 100644 index 0000000..a19bee8 --- /dev/null +++ b/src/api-client.ts @@ -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): 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 { + 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 { + 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 { + const headers: Record = { + 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 { + const headers: Record = { + 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 { + 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 { + 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; + } + } +} diff --git a/src/chat-view.ts b/src/chat-view.ts new file mode 100644 index 0000000..c8641d4 --- /dev/null +++ b/src/chat-view.ts @@ -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; + + streamChat( + messages: { role: string; content: string }[], + onDelta: (text: string) => void + ): Promise; +} + +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 { + 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 { + // 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 { + 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); + } + } +} \ No newline at end of file diff --git a/src/context-bridge.ts b/src/context-bridge.ts new file mode 100644 index 0000000..7a274cf --- /dev/null +++ b/src/context-bridge.ts @@ -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; + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b90a2c0 --- /dev/null +++ b/src/main.ts @@ -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); + } + } + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..36a6ce8 --- /dev/null +++ b/src/settings.ts @@ -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); + }) + ); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3d499ce --- /dev/null +++ b/src/types.ts @@ -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; + }; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..7b8a38a --- /dev/null +++ b/styles.css @@ -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); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aa2eb4a --- /dev/null +++ b/tsconfig.json @@ -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"] +}