From 6bdea9d13fd45171102baf1aee35f7b06d72a31a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Jan 2026 05:45:38 +0000 Subject: [PATCH 01/40] Add backend service for Claude agent Implements the backend WebSocket server that: - Connects to Claude API with streaming support - Exposes vault operation tools (read, write, search, list, delete) - Uses bidirectional RPC protocol for vault operations - Supports token-based authentication - Includes Dockerfile for Railway deployment The backend acts as a bridge between the Obsidian plugin and Claude, handling the agent loop and tool execution. --- backend/.env.example | 15 + backend/.gitignore | 27 + backend/Dockerfile | 26 + backend/README.md | 94 ++++ backend/package-lock.json | 1092 +++++++++++++++++++++++++++++++++++++ backend/package.json | 30 + backend/src/agent.ts | 238 ++++++++ backend/src/index.ts | 73 +++ backend/src/mcp-tools.ts | 214 ++++++++ backend/src/protocol.ts | 208 +++++++ backend/src/server.ts | 321 +++++++++++ backend/src/utils.ts | 112 ++++ backend/tsconfig.json | 20 + 13 files changed, 2470 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/src/agent.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/mcp-tools.ts create mode 100644 backend/src/protocol.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/utils.ts create mode 100644 backend/tsconfig.json diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..46840b36 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# Anthropic API Key (required) +ANTHROPIC_API_KEY=sk-ant-... + +# Authentication token for WebSocket connections +# Generate with: openssl rand -hex 32 +AUTH_TOKEN=your-secret-token-here + +# Server port (default: 3001) +PORT=3001 + +# Claude model to use (default: claude-sonnet-4-20250514) +CLAUDE_MODEL=claude-sonnet-4-20250514 + +# Log level: debug, info, warn, error (default: info) +LOG_LEVEL=info diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..02b58097 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,27 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Test coverage +coverage/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..d60be897 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM node:22-slim + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install production dependencies +RUN npm ci --only=production + +# Copy built files +COPY dist ./dist + +# Set environment +ENV NODE_ENV=production +ENV PORT=3001 + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "const http = require('http'); const req = http.request({hostname: 'localhost', port: process.env.PORT || 3001, path: '/', method: 'GET', timeout: 2000}, (res) => process.exit(res.statusCode === 426 ? 0 : 1)); req.on('error', () => process.exit(1)); req.end();" || exit 1 + +# Run the server +CMD ["node", "dist/index.js"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..951bcd1e --- /dev/null +++ b/backend/README.md @@ -0,0 +1,94 @@ +# Obsidian Claude Agent Backend + +Backend service that powers Claude-based note editing in Obsidian. Runs the Claude API with vault operation tools and communicates with the Obsidian plugin over WebSocket. + +## Quick Start + +```bash +# Install dependencies +npm install + +# Copy and configure environment +cp .env.example .env +# Edit .env with your ANTHROPIC_API_KEY and AUTH_TOKEN + +# Run in development mode +npm run dev + +# Build for production +npm run build +npm start +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `ANTHROPIC_API_KEY` | Yes | - | Your Anthropic API key | +| `AUTH_TOKEN` | No | `dev-token` | Token for WebSocket authentication | +| `PORT` | No | `3001` | Server port | +| `CLAUDE_MODEL` | No | `claude-sonnet-4-20250514` | Claude model to use | +| `LOG_LEVEL` | No | `info` | Logging level (debug/info/warn/error) | + +## Deployment + +### Railway + +1. Create a new Railway project from this repo +2. Add environment variables in Railway dashboard +3. Deploy - Railway auto-detects the Dockerfile + +### Docker + +```bash +# Build +npm run build +docker build -t obsidian-claude-backend . + +# Run +docker run -p 3001:3001 \ + -e ANTHROPIC_API_KEY=your-key \ + -e AUTH_TOKEN=your-token \ + obsidian-claude-backend +``` + +## WebSocket Protocol + +Connect to `ws://localhost:3001?token=YOUR_AUTH_TOKEN` + +### Client Messages + +- `prompt`: Send a message to the agent +- `cancel`: Cancel an ongoing request +- `rpc_response`: Response to a vault operation request +- `ping`: Keepalive + +### Server Messages + +- `text_delta`: Streaming text from agent +- `tool_start`/`tool_end`: Agent tool usage +- `complete`: Agent finished +- `error`: Error occurred +- `rpc_request`: Request to perform vault operation +- `pong`: Keepalive response + +## Architecture + +``` +┌─────────────────────────────────────┐ +│ WebSocket Server │ +│ (handles connections, auth) │ +└─────────────────┬───────────────────┘ + │ +┌─────────────────▼───────────────────┐ +│ Agent Loop │ +│ (Anthropic SDK with streaming) │ +└─────────────────┬───────────────────┘ + │ +┌─────────────────▼───────────────────┐ +│ Vault Tools │ +│ (read, write, search, list, del) │ +└─────────────────────────────────────┘ +``` + +The agent uses tools that send RPC requests to the connected Obsidian plugin, which executes the actual vault operations. diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 00000000..a487d477 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1092 @@ +{ + "name": "obsidian-claude-agent-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-claude-agent-backend", + "version": "1.0.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "dotenv": "^16.4.0", + "uuid": "^10.0.0", + "ws": "^8.18.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "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==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..69eb7b55 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "obsidian-claude-agent-backend", + "version": "1.0.0", + "description": "Backend service for Obsidian Claude Agent plugin", + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "ws": "^8.18.0", + "zod": "^3.23.0", + "dotenv": "^16.4.0", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/backend/src/agent.ts b/backend/src/agent.ts new file mode 100644 index 00000000..6fc104aa --- /dev/null +++ b/backend/src/agent.ts @@ -0,0 +1,238 @@ +/** + * Claude Agent Integration + * + * Implements the agent loop using the Anthropic SDK with streaming + * and tool use support for vault operations. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { + getVaultToolDefinitions, + executeVaultTool, + type ToolDefinition, +} from './mcp-tools.js'; +import { logger } from './utils.js'; +import type { + VaultBridge, + AgentContext, + AgentEvent, +} from './protocol.js'; + +const SYSTEM_PROMPT = `You are an Obsidian note-editing assistant. You help users create, edit, search, and organize their notes in their Obsidian vault. + +## Capabilities +- Read notes from the vault +- Write/create notes +- Search across the vault +- List files and folders +- Delete notes (ask for confirmation first) + +## Guidelines +1. When editing existing notes, ALWAYS read them first to understand current content +2. Preserve existing formatting and structure unless asked to change it +3. Use proper Obsidian markdown: + - [[wikilinks]] for internal links + - #tags for categorization + - YAML frontmatter for metadata +4. When creating new notes, suggest appropriate folder locations +5. For destructive operations (delete, overwrite), confirm with the user first +6. If a search returns no results, suggest alternative search terms + +## Response Style +- Be concise but helpful +- Explain what changes you're making +- If uncertain, ask for clarification`; + +const MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514'; +const MAX_TOKENS = 4096; +const MAX_ITERATIONS = 10; // Prevent infinite tool loops + +interface ConversationMessage { + role: 'user' | 'assistant'; + content: string | Anthropic.ContentBlock[]; +} + +/** + * Run the agent with streaming responses + */ +export async function* runAgent( + prompt: string, + bridge: VaultBridge, + context?: AgentContext, + signal?: AbortSignal +): AsyncGenerator { + const client = new Anthropic(); + const tools = getVaultToolDefinitions(); + const messages: ConversationMessage[] = []; + + // Build context-aware prompt + let fullPrompt = prompt; + if (context?.currentFile) { + fullPrompt = `[Currently viewing: ${context.currentFile}]\n\n${prompt}`; + } + if (context?.selection) { + fullPrompt = `[Selected text: "${context.selection}"]\n\n${fullPrompt}`; + } + + messages.push({ role: 'user', content: fullPrompt }); + + let iteration = 0; + let continueLoop = true; + + while (continueLoop && iteration < MAX_ITERATIONS) { + if (signal?.aborted) { + yield { type: 'complete', result: 'Cancelled by user' }; + return; + } + + iteration++; + logger.debug(`Agent iteration ${iteration}`); + + try { + // Create streaming request + const stream = await client.messages.stream({ + model: MODEL, + max_tokens: MAX_TOKENS, + system: SYSTEM_PROMPT, + tools: tools as Anthropic.Tool[], + messages: messages as Anthropic.MessageParam[], + }); + + let currentText = ''; + const toolUses: Array<{ + id: string; + name: string; + input: Record; + }> = []; + + // Process streaming events + for await (const event of stream) { + if (signal?.aborted) { + yield { type: 'complete', result: 'Cancelled by user' }; + return; + } + + if (event.type === 'content_block_delta') { + const delta = event.delta; + if ('text' in delta && delta.text) { + currentText += delta.text; + yield { type: 'text_delta', text: delta.text }; + } else if ('partial_json' in delta) { + // Tool input is being streamed - we'll handle it when complete + } + } else if (event.type === 'content_block_start') { + const block = event.content_block; + if (block.type === 'tool_use') { + toolUses.push({ + id: block.id, + name: block.name, + input: {}, + }); + } + } else if (event.type === 'message_delta') { + // Message is complete + } + } + + // Get the final message to extract complete tool inputs + const finalMessage = await stream.finalMessage(); + + // Build assistant message content + const assistantContent: Anthropic.ContentBlock[] = []; + + for (const block of finalMessage.content) { + if (block.type === 'text') { + assistantContent.push(block); + } else if (block.type === 'tool_use') { + assistantContent.push(block); + // Update tool input from final message + const toolUse = toolUses.find((t) => t.id === block.id); + if (toolUse) { + toolUse.input = block.input as Record; + } else { + toolUses.push({ + id: block.id, + name: block.name, + input: block.input as Record, + }); + } + } + } + + // Add assistant message to history + messages.push({ role: 'assistant', content: assistantContent }); + + // Check if we need to execute tools + if (finalMessage.stop_reason === 'tool_use' && toolUses.length > 0) { + const toolResults: Anthropic.ToolResultBlockParam[] = []; + + for (const toolUse of toolUses) { + if (signal?.aborted) { + yield { type: 'complete', result: 'Cancelled by user' }; + return; + } + + yield { + type: 'tool_start', + name: toolUse.name, + input: toolUse.input, + }; + + const result = await executeVaultTool( + toolUse.name, + toolUse.input, + bridge + ); + + yield { + type: 'tool_end', + name: toolUse.name, + result: result.content, + }; + + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: result.content, + is_error: result.isError, + }); + } + + // Add tool results to messages + messages.push({ role: 'user', content: toolResults as unknown as string }); + + // Continue the loop to get the next response + continueLoop = true; + } else { + // Agent is done + continueLoop = false; + yield { type: 'complete', result: currentText }; + } + } catch (err) { + logger.error('Agent error:', err); + + if (err instanceof Anthropic.APIError) { + yield { + type: 'error', + code: `API_ERROR_${err.status}`, + message: err.message, + }; + } else { + yield { + type: 'error', + code: 'AGENT_ERROR', + message: err instanceof Error ? err.message : 'Unknown error', + }; + } + return; + } + } + + if (iteration >= MAX_ITERATIONS) { + yield { + type: 'error', + code: 'MAX_ITERATIONS', + message: 'Agent reached maximum iterations limit', + }; + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 00000000..871bd12c --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,73 @@ +/** + * Obsidian Claude Agent Backend + * + * Entry point for the backend service that connects + * Claude to Obsidian vault operations via WebSocket. + */ + +import 'dotenv/config'; +import { startServer } from './server.js'; +import { logger } from './utils.js'; + +// Verify required environment variables +function checkEnv() { + const required = ['ANTHROPIC_API_KEY']; + const missing = required.filter((key) => !process.env[key]); + + if (missing.length > 0) { + logger.error(`Missing required environment variables: ${missing.join(', ')}`); + process.exit(1); + } + + // Log configuration (without sensitive values) + logger.info('Configuration:'); + logger.info(` PORT: ${process.env.PORT || 3001}`); + logger.info(` AUTH_TOKEN: ${process.env.AUTH_TOKEN ? '***' : 'dev-token (default)'}`); + logger.info(` CLAUDE_MODEL: ${process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514'}`); + logger.info(` LOG_LEVEL: ${process.env.LOG_LEVEL || 'info'}`); +} + +// Handle uncaught errors +process.on('uncaughtException', (err) => { + logger.error('Uncaught exception:', err); + process.exit(1); +}); + +process.on('unhandledRejection', (reason) => { + logger.error('Unhandled rejection:', reason); + process.exit(1); +}); + +// Graceful shutdown +function setupGracefulShutdown(server: ReturnType) { + const shutdown = (signal: string) => { + logger.info(`Received ${signal}, shutting down gracefully...`); + server.close(() => { + logger.info('Server closed'); + process.exit(0); + }); + + // Force exit after 10 seconds + setTimeout(() => { + logger.warn('Forced shutdown after timeout'); + process.exit(1); + }, 10000); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +// Main entry point +function main() { + logger.info('Starting Obsidian Claude Agent Backend...'); + + checkEnv(); + + const server = startServer(); + setupGracefulShutdown(server); + + logger.info('Backend ready'); +} + +main(); diff --git a/backend/src/mcp-tools.ts b/backend/src/mcp-tools.ts new file mode 100644 index 00000000..7e4c424e --- /dev/null +++ b/backend/src/mcp-tools.ts @@ -0,0 +1,214 @@ +/** + * MCP Tool Definitions for Vault Operations + * + * Defines the tools that the Claude agent can use to interact + * with the Obsidian vault through the plugin. + */ + +import type { VaultBridge } from './protocol.js'; +import { logger, truncate } from './utils.js'; + +/** + * Tool definition for Claude's tool_use + */ +export interface ToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +/** + * Tool execution result + */ +export interface ToolResult { + content: string; + isError?: boolean; +} + +/** + * Get all vault tool definitions + */ +export function getVaultToolDefinitions(): ToolDefinition[] { + return [ + { + name: 'vault_read', + description: + 'Read the content of a note from the vault. Returns the full markdown content including frontmatter. Use this before editing any existing note.', + input_schema: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Path relative to vault root, e.g. "folder/note.md" or "note.md"', + }, + }, + required: ['path'], + }, + }, + { + name: 'vault_write', + description: + 'Write content to a note. Creates the file if it does not exist, overwrites if it does. Parent folders are created automatically. Always read a note first before overwriting it.', + input_schema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path relative to vault root', + }, + content: { + type: 'string', + description: 'Full markdown content to write', + }, + }, + required: ['path', 'content'], + }, + }, + { + name: 'vault_search', + description: + 'Search for notes by content or filename. Returns matching file paths with content snippets. Useful for finding relevant notes before reading them.', + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query - matches against filenames and content', + }, + limit: { + type: 'number', + description: 'Maximum results to return (default: 20)', + }, + }, + required: ['query'], + }, + }, + { + name: 'vault_list', + description: + 'List files and folders in a directory. Use empty string or "/" for vault root.', + input_schema: { + type: 'object', + properties: { + folder: { + type: 'string', + description: 'Folder path relative to vault root, empty for root', + }, + }, + required: [], + }, + }, + { + name: 'vault_delete', + description: + 'Delete a note from the vault. The file will be moved to system trash. Use with caution - always confirm with user first before deleting.', + input_schema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path of the note to delete', + }, + }, + required: ['path'], + }, + }, + ]; +} + +/** + * Execute a vault tool with the given input + */ +export async function executeVaultTool( + toolName: string, + input: Record, + bridge: VaultBridge +): Promise { + logger.debug(`Executing tool: ${toolName}`, input); + + try { + switch (toolName) { + case 'vault_read': { + const path = input.path as string; + const content = await bridge.read(path); + return { + content: content, + }; + } + + case 'vault_write': { + const path = input.path as string; + const content = input.content as string; + await bridge.write(path, content); + return { + content: `Successfully wrote ${content.length} characters to ${path}`, + }; + } + + case 'vault_search': { + const query = input.query as string; + const limit = (input.limit as number) || 20; + const results = await bridge.search(query, limit); + + if (results.length === 0) { + return { + content: 'No matching notes found.', + }; + } + + const formatted = results + .map((r) => `- ${r.path}: ${truncate(r.snippet, 100)}`) + .join('\n'); + return { + content: `Found ${results.length} result(s):\n${formatted}`, + }; + } + + case 'vault_list': { + const folder = (input.folder as string) || ''; + const items = await bridge.list(folder); + + if (items.length === 0) { + return { + content: folder + ? `Folder "${folder}" is empty.` + : 'Vault is empty.', + }; + } + + const formatted = items + .map((i) => `- ${i.type === 'folder' ? '📁' : '📄'} ${i.name}`) + .join('\n'); + return { + content: `Contents of ${folder || 'vault root'}:\n${formatted}`, + }; + } + + case 'vault_delete': { + const path = input.path as string; + await bridge.delete(path); + return { + content: `Deleted ${path}`, + }; + } + + default: + return { + content: `Unknown tool: ${toolName}`, + isError: true, + }; + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + logger.error(`Tool ${toolName} failed:`, message); + return { + content: `Error: ${message}`, + isError: true, + }; + } +} diff --git a/backend/src/protocol.ts b/backend/src/protocol.ts new file mode 100644 index 00000000..7ec72c40 --- /dev/null +++ b/backend/src/protocol.ts @@ -0,0 +1,208 @@ +/** + * WebSocket Protocol Types + * + * Defines the bidirectional message types for communication between + * the backend server and the Obsidian plugin. + */ + +// ============================================================================ +// Shared Types +// ============================================================================ + +export interface SearchResult { + path: string; + snippet: string; + score?: number; +} + +export interface FileInfo { + name: string; + path: string; + type: 'file' | 'folder'; +} + +export interface AgentContext { + currentFile?: string; + selection?: string; +} + +// ============================================================================ +// Client → Server Messages +// ============================================================================ + +/** Send a prompt to the agent */ +export interface PromptMessage { + type: 'prompt'; + id: string; + prompt: string; + context?: AgentContext; +} + +/** Response to an RPC request from server */ +export interface RpcResponseMessage { + type: 'rpc_response'; + id: string; + result?: unknown; + error?: { + code: string; + message: string; + }; +} + +/** Cancel ongoing agent operation */ +export interface CancelMessage { + type: 'cancel'; + id: string; +} + +/** Keepalive ping */ +export interface PingMessage { + type: 'ping'; +} + +export type ClientMessage = + | PromptMessage + | RpcResponseMessage + | CancelMessage + | PingMessage; + +// ============================================================================ +// Server → Client Messages +// ============================================================================ + +/** Streaming text from agent */ +export interface TextDeltaMessage { + type: 'text_delta'; + requestId: string; + text: string; +} + +/** Agent is using a tool */ +export interface ToolStartMessage { + type: 'tool_start'; + requestId: string; + toolName: string; + toolInput: Record; +} + +/** Tool finished */ +export interface ToolEndMessage { + type: 'tool_end'; + requestId: string; + toolName: string; + result: string; +} + +/** Agent thinking (for transparency) */ +export interface ThinkingMessage { + type: 'thinking'; + requestId: string; + text: string; +} + +/** Agent finished */ +export interface CompleteMessage { + type: 'complete'; + requestId: string; + result: string; +} + +/** Error occurred */ +export interface ErrorMessage { + type: 'error'; + requestId?: string; + code: string; + message: string; +} + +/** RPC request - server asking plugin to perform vault operation */ +export interface RpcRequestMessage { + type: 'rpc_request'; + id: string; + method: 'vault_read' | 'vault_write' | 'vault_search' | 'vault_list' | 'vault_delete'; + params: Record; +} + +/** Keepalive response */ +export interface PongMessage { + type: 'pong'; +} + +export type ServerMessage = + | TextDeltaMessage + | ToolStartMessage + | ToolEndMessage + | ThinkingMessage + | CompleteMessage + | ErrorMessage + | RpcRequestMessage + | PongMessage; + +// ============================================================================ +// Agent Events (internal) +// ============================================================================ + +export type AgentEventType = + | 'text_delta' + | 'tool_start' + | 'tool_end' + | 'thinking' + | 'complete' + | 'error'; + +export interface BaseAgentEvent { + type: AgentEventType; +} + +export interface TextDeltaEvent extends BaseAgentEvent { + type: 'text_delta'; + text: string; +} + +export interface ToolStartEvent extends BaseAgentEvent { + type: 'tool_start'; + name: string; + input: Record; +} + +export interface ToolEndEvent extends BaseAgentEvent { + type: 'tool_end'; + name: string; + result: string; +} + +export interface ThinkingEvent extends BaseAgentEvent { + type: 'thinking'; + text: string; +} + +export interface CompleteEvent extends BaseAgentEvent { + type: 'complete'; + result: string; +} + +export interface ErrorEvent extends BaseAgentEvent { + type: 'error'; + code: string; + message: string; +} + +export type AgentEvent = + | TextDeltaEvent + | ToolStartEvent + | ToolEndEvent + | ThinkingEvent + | CompleteEvent + | ErrorEvent; + +// ============================================================================ +// Vault Bridge Interface +// ============================================================================ + +export interface VaultBridge { + read(path: string): Promise; + write(path: string, content: string): Promise; + search(query: string, limit?: number): Promise; + list(folder: string): Promise; + delete(path: string): Promise; +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 00000000..baab2027 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,321 @@ +/** + * WebSocket Server + * + * Handles connections from the Obsidian plugin, routes messages, + * and manages the bidirectional RPC protocol. + */ + +import { WebSocketServer, WebSocket } from 'ws'; +import { randomUUID } from 'crypto'; +import type { IncomingMessage } from 'http'; +import { runAgent } from './agent.js'; +import { logger } from './utils.js'; +import type { + ClientMessage, + ServerMessage, + PromptMessage, + RpcResponseMessage, + CancelMessage, + VaultBridge, + SearchResult, + FileInfo, +} from './protocol.js'; + +const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token'; +const PORT = parseInt(process.env.PORT || '3001', 10); + +interface PendingRpc { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +} + +/** + * Handles a single WebSocket connection + */ +class ConnectionHandler { + private pendingRpcs = new Map(); + private activeRequests = new Map(); + private isAlive = true; + + constructor(private ws: WebSocket) { + ws.on('message', (data) => this.handleMessage(data.toString())); + ws.on('close', () => this.cleanup()); + ws.on('error', (err) => logger.error('WebSocket error:', err)); + ws.on('pong', () => { + this.isAlive = true; + }); + } + + /** + * Check if the connection is still alive (for heartbeat) + */ + checkAlive(): boolean { + if (!this.isAlive) { + logger.warn('Connection heartbeat timeout, terminating'); + this.ws.terminate(); + return false; + } + this.isAlive = false; + this.ws.ping(); + return true; + } + + private async handleMessage(raw: string) { + let msg: ClientMessage; + try { + msg = JSON.parse(raw); + } catch { + this.send({ type: 'error', code: 'INVALID_JSON', message: 'Invalid JSON' }); + return; + } + + logger.debug('Received message:', msg.type); + + switch (msg.type) { + case 'prompt': + await this.handlePrompt(msg); + break; + case 'rpc_response': + this.handleRpcResponse(msg); + break; + case 'cancel': + this.handleCancel(msg); + break; + case 'ping': + this.send({ type: 'pong' }); + break; + } + } + + private async handlePrompt(msg: PromptMessage) { + const abortController = new AbortController(); + this.activeRequests.set(msg.id, abortController); + + logger.info(`Processing prompt request ${msg.id}`); + + try { + // Create vault bridge that sends RPC requests to plugin + const vaultBridge = this.createVaultBridge(); + + for await (const event of runAgent( + msg.prompt, + vaultBridge, + msg.context, + abortController.signal + )) { + if (abortController.signal.aborted) { + logger.info(`Request ${msg.id} was cancelled`); + break; + } + + switch (event.type) { + case 'text_delta': + this.send({ + type: 'text_delta', + requestId: msg.id, + text: event.text, + }); + break; + case 'tool_start': + this.send({ + type: 'tool_start', + requestId: msg.id, + toolName: event.name, + toolInput: event.input, + }); + break; + case 'tool_end': + this.send({ + type: 'tool_end', + requestId: msg.id, + toolName: event.name, + result: event.result, + }); + break; + case 'thinking': + this.send({ + type: 'thinking', + requestId: msg.id, + text: event.text, + }); + break; + case 'complete': + this.send({ + type: 'complete', + requestId: msg.id, + result: event.result, + }); + break; + case 'error': + this.send({ + type: 'error', + requestId: msg.id, + code: event.code, + message: event.message, + }); + break; + } + } + + logger.info(`Completed prompt request ${msg.id}`); + } catch (err) { + logger.error(`Error processing prompt ${msg.id}:`, err); + this.send({ + type: 'error', + requestId: msg.id, + code: 'AGENT_ERROR', + message: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + this.activeRequests.delete(msg.id); + } + } + + private handleRpcResponse(msg: RpcResponseMessage) { + const pending = this.pendingRpcs.get(msg.id); + if (!pending) { + logger.warn(`Received RPC response for unknown request: ${msg.id}`); + return; + } + + clearTimeout(pending.timeout); + this.pendingRpcs.delete(msg.id); + + if (msg.error) { + pending.reject(new Error(msg.error.message)); + } else { + pending.resolve(msg.result); + } + } + + private handleCancel(msg: CancelMessage) { + const controller = this.activeRequests.get(msg.id); + if (controller) { + logger.info(`Cancelling request ${msg.id}`); + controller.abort(); + } + } + + /** + * Send RPC request to plugin and wait for response + */ + async sendRpc( + method: string, + params: Record + ): Promise { + const id = randomUUID(); + const RPC_TIMEOUT = 30000; // 30 seconds + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingRpcs.delete(id); + reject(new Error(`RPC timeout: ${method}`)); + }, RPC_TIMEOUT); + + this.pendingRpcs.set(id, { + resolve: resolve as (result: unknown) => void, + reject, + timeout, + }); + + logger.debug(`Sending RPC request: ${method}`, params); + this.send({ type: 'rpc_request', id, method: method as 'vault_read', params }); + }); + } + + private createVaultBridge(): VaultBridge { + return { + read: async (path: string): Promise => { + const result = await this.sendRpc<{ content: string }>( + 'vault_read', + { path } + ); + return result.content; + }, + write: async (path: string, content: string): Promise => { + await this.sendRpc('vault_write', { path, content }); + }, + search: async ( + query: string, + limit: number = 20 + ): Promise => { + return this.sendRpc('vault_search', { query, limit }); + }, + list: async (folder: string): Promise => { + return this.sendRpc('vault_list', { folder }); + }, + delete: async (path: string): Promise => { + await this.sendRpc('vault_delete', { path }); + }, + }; + } + + private send(msg: ServerMessage) { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + + private cleanup() { + logger.info('Connection closed, cleaning up'); + + // Cancel all pending RPCs + for (const [id, pending] of this.pendingRpcs) { + clearTimeout(pending.timeout); + pending.reject(new Error('Connection closed')); + } + this.pendingRpcs.clear(); + + // Abort all active requests + for (const controller of this.activeRequests.values()) { + controller.abort(); + } + this.activeRequests.clear(); + } +} + +/** + * Start the WebSocket server + */ +export function startServer(): WebSocketServer { + const wss = new WebSocketServer({ port: PORT }); + const connections = new Map(); + + // Heartbeat interval to detect dead connections + const heartbeatInterval = setInterval(() => { + for (const [ws, handler] of connections) { + if (!handler.checkAlive()) { + connections.delete(ws); + } + } + }, 30000); + + wss.on('connection', (ws: WebSocket, req: IncomingMessage) => { + // Simple token auth via query param + const url = new URL(req.url || '', `http://localhost:${PORT}`); + const token = url.searchParams.get('token'); + + if (token !== AUTH_TOKEN) { + logger.warn('Unauthorized connection attempt'); + ws.close(4001, 'Unauthorized'); + return; + } + + logger.info('Client connected'); + const handler = new ConnectionHandler(ws); + connections.set(ws, handler); + + ws.on('close', () => { + connections.delete(ws); + logger.info('Client disconnected'); + }); + }); + + wss.on('close', () => { + clearInterval(heartbeatInterval); + }); + + logger.info(`WebSocket server running on port ${PORT}`); + return wss; +} diff --git a/backend/src/utils.ts b/backend/src/utils.ts new file mode 100644 index 00000000..f82c7973 --- /dev/null +++ b/backend/src/utils.ts @@ -0,0 +1,112 @@ +/** + * Utility functions for the backend service + */ + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +const currentLogLevel: LogLevel = + (process.env.LOG_LEVEL as LogLevel) || 'info'; + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[currentLogLevel]; +} + +function formatTimestamp(): string { + return new Date().toISOString(); +} + +export const logger = { + debug(message: string, ...args: unknown[]): void { + if (shouldLog('debug')) { + console.log(`[${formatTimestamp()}] [DEBUG] ${message}`, ...args); + } + }, + + info(message: string, ...args: unknown[]): void { + if (shouldLog('info')) { + console.log(`[${formatTimestamp()}] [INFO] ${message}`, ...args); + } + }, + + warn(message: string, ...args: unknown[]): void { + if (shouldLog('warn')) { + console.warn(`[${formatTimestamp()}] [WARN] ${message}`, ...args); + } + }, + + error(message: string, ...args: unknown[]): void { + if (shouldLog('error')) { + console.error(`[${formatTimestamp()}] [ERROR] ${message}`, ...args); + } + }, +}; + +/** + * Simple exponential backoff helper + */ +export async function withRetry( + fn: () => Promise, + options: { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + } = {} +): Promise { + const { maxAttempts = 3, baseDelayMs = 1000, maxDelayMs = 30000 } = options; + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt === maxAttempts) { + break; + } + + const delay = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs); + logger.warn( + `Attempt ${attempt}/${maxAttempts} failed, retrying in ${delay}ms:`, + lastError.message + ); + await sleep(delay); + } + } + + throw lastError; +} + +/** + * Sleep for a specified duration + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Safely parse JSON with a fallback + */ +export function safeJsonParse(json: string, fallback: T): T { + try { + return JSON.parse(json) as T; + } catch { + return fallback; + } +} + +/** + * Truncate a string to a maximum length with ellipsis + */ +export function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str; + return str.substring(0, maxLength - 3) + '...'; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 00000000..c83c5333 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 94a25c2cfdb951766df4edda029a428e83e796af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Jan 2026 17:41:07 +0000 Subject: [PATCH 02/40] Add mock mode and test suite for backend - Add MOCK_MODE environment variable for testing without API key - Add mock agent that simulates Claude responses with tool usage - Add automated test suite with 6 tests covering: - Simple prompt/response streaming - vault_list, vault_search, vault_read, vault_write tools - Ping/pong keepalive - Add interactive test client for manual testing - Update package.json with dev:mock and test scripts All tests pass: 6/6 --- backend/package.json | 6 +- backend/src/index.ts | 20 +- backend/src/mock-agent.ts | 209 +++++++++++++++++++ backend/src/server.ts | 8 +- backend/test/automated-test.ts | 347 +++++++++++++++++++++++++++++++ backend/test/test-client.ts | 369 +++++++++++++++++++++++++++++++++ 6 files changed, 951 insertions(+), 8 deletions(-) create mode 100644 backend/src/mock-agent.ts create mode 100644 backend/test/automated-test.ts create mode 100644 backend/test/test-client.ts diff --git a/backend/package.json b/backend/package.json index 69eb7b55..7a10cb02 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,9 +6,13 @@ "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", + "dev:mock": "MOCK_MODE=true tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "typecheck": "tsc --noEmit" + "start:mock": "MOCK_MODE=true node dist/index.js", + "typecheck": "tsc --noEmit", + "test:client": "tsx test/test-client.ts", + "test:auto": "tsx test/automated-test.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index 871bd12c..3efaa556 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,21 +9,29 @@ import 'dotenv/config'; import { startServer } from './server.js'; import { logger } from './utils.js'; +const MOCK_MODE = process.env.MOCK_MODE === 'true'; + // Verify required environment variables function checkEnv() { - const required = ['ANTHROPIC_API_KEY']; - const missing = required.filter((key) => !process.env[key]); + // In mock mode, we don't need the API key + if (!MOCK_MODE) { + const required = ['ANTHROPIC_API_KEY']; + const missing = required.filter((key) => !process.env[key]); - if (missing.length > 0) { - logger.error(`Missing required environment variables: ${missing.join(', ')}`); - process.exit(1); + if (missing.length > 0) { + logger.error(`Missing required environment variables: ${missing.join(', ')}`); + process.exit(1); + } } // Log configuration (without sensitive values) logger.info('Configuration:'); + logger.info(` MOCK_MODE: ${MOCK_MODE}`); logger.info(` PORT: ${process.env.PORT || 3001}`); logger.info(` AUTH_TOKEN: ${process.env.AUTH_TOKEN ? '***' : 'dev-token (default)'}`); - logger.info(` CLAUDE_MODEL: ${process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514'}`); + if (!MOCK_MODE) { + logger.info(` CLAUDE_MODEL: ${process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514'}`); + } logger.info(` LOG_LEVEL: ${process.env.LOG_LEVEL || 'info'}`); } diff --git a/backend/src/mock-agent.ts b/backend/src/mock-agent.ts new file mode 100644 index 00000000..60e1e469 --- /dev/null +++ b/backend/src/mock-agent.ts @@ -0,0 +1,209 @@ +/** + * Mock Agent for Testing + * + * Simulates Claude's behavior for testing the WebSocket server + * and RPC protocol without making real API calls. + */ + +import { logger } from './utils.js'; +import type { + VaultBridge, + AgentContext, + AgentEvent, +} from './protocol.js'; +import { executeVaultTool } from './mcp-tools.js'; + +const MOCK_DELAY_MS = 50; // Delay between streaming chunks + +/** + * Sleep helper for simulating streaming delays + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Simulate streaming text by yielding character by character + */ +async function* streamText( + text: string, + signal?: AbortSignal +): AsyncGenerator { + const words = text.split(' '); + for (const word of words) { + if (signal?.aborted) return; + yield word + ' '; + await sleep(MOCK_DELAY_MS); + } +} + +/** + * Mock agent scenarios based on user input + */ +interface MockScenario { + response: string; + tools?: Array<{ + name: string; + input: Record; + }>; + followUp?: string; +} + +function getMockScenario(prompt: string): MockScenario { + const promptLower = prompt.toLowerCase(); + + // Test tool: vault_list + if (promptLower.includes('list') || promptLower.includes('show files')) { + return { + response: "I'll list the files in your vault for you.", + tools: [{ name: 'vault_list', input: { folder: '' } }], + followUp: 'Here are the files I found in your vault root.', + }; + } + + // Test tool: vault_search + if (promptLower.includes('search') || promptLower.includes('find')) { + const match = prompt.match(/(?:search|find)\s+(?:for\s+)?["']?([^"']+)["']?/i); + const query = match?.[1] || 'test'; + return { + response: `I'll search your vault for "${query}".`, + tools: [{ name: 'vault_search', input: { query, limit: 10 } }], + followUp: 'Here are the search results.', + }; + } + + // Test tool: vault_read + if (promptLower.includes('read') || promptLower.includes('open') || promptLower.includes('show me')) { + const match = prompt.match(/(?:read|open|show me)\s+["']?([^"'\s]+\.md)["']?/i); + const path = match?.[1] || 'test.md'; + return { + response: `I'll read the contents of "${path}" for you.`, + tools: [{ name: 'vault_read', input: { path } }], + followUp: 'Here is the content of the file.', + }; + } + + // Test tool: vault_write + if (promptLower.includes('create') || promptLower.includes('write') || promptLower.includes('new note')) { + const pathMatch = prompt.match(/(?:create|write|new note)\s+["']?([^"'\s]+\.md)["']?/i); + const path = pathMatch?.[1] || 'new-note.md'; + return { + response: `I'll create a new note at "${path}".`, + tools: [{ + name: 'vault_write', + input: { + path, + content: `# New Note\n\nThis is a test note created by the mock agent.\n\nCreated: ${new Date().toISOString()}\n`, + }, + }], + followUp: `Successfully created the note at "${path}".`, + }; + } + + // Test tool: vault_delete (with warning) + if (promptLower.includes('delete') || promptLower.includes('remove')) { + const match = prompt.match(/(?:delete|remove)\s+["']?([^"'\s]+\.md)["']?/i); + const path = match?.[1] || 'test.md'; + return { + response: `⚠️ Are you sure you want to delete "${path}"? This will move it to trash. For this mock test, I'll proceed with the deletion.`, + tools: [{ name: 'vault_delete', input: { path } }], + followUp: `The file "${path}" has been moved to trash.`, + }; + } + + // Test multiple tools + if (promptLower.includes('multi') || promptLower.includes('several')) { + return { + response: "I'll demonstrate using multiple tools in sequence.", + tools: [ + { name: 'vault_list', input: { folder: '' } }, + { name: 'vault_search', input: { query: 'test', limit: 5 } }, + ], + followUp: 'I used multiple tools to gather information from your vault.', + }; + } + + // Default: simple response without tools + return { + response: `Hello! I'm the mock Obsidian assistant. I received your message: "${prompt}"\n\nI can help you with:\n- **list files** - List vault contents\n- **search [query]** - Search your notes\n- **read [file.md]** - Read a note\n- **create [file.md]** - Create a new note\n- **delete [file.md]** - Delete a note\n- **multi** - Test multiple tools\n\nTry one of these commands to see the mock tools in action!`, + }; +} + +/** + * Run the mock agent with simulated streaming responses + */ +export async function* runMockAgent( + prompt: string, + bridge: VaultBridge, + context?: AgentContext, + signal?: AbortSignal +): AsyncGenerator { + logger.info('[MOCK] Running mock agent'); + + // Build context-aware prompt for logging + let fullPrompt = prompt; + if (context?.currentFile) { + fullPrompt = `[Currently viewing: ${context.currentFile}]\n\n${prompt}`; + logger.debug(`[MOCK] Context - current file: ${context.currentFile}`); + } + if (context?.selection) { + fullPrompt = `[Selected text: "${context.selection}"]\n\n${fullPrompt}`; + logger.debug(`[MOCK] Context - selection: ${context.selection}`); + } + + const scenario = getMockScenario(prompt); + + // Stream the initial response + for await (const chunk of streamText(scenario.response, signal)) { + if (signal?.aborted) { + yield { type: 'complete', result: 'Cancelled by user' }; + return; + } + yield { type: 'text_delta', text: chunk }; + } + + // Execute tools if any + if (scenario.tools && scenario.tools.length > 0) { + for (const tool of scenario.tools) { + if (signal?.aborted) { + yield { type: 'complete', result: 'Cancelled by user' }; + return; + } + + yield { + type: 'tool_start', + name: tool.name, + input: tool.input, + }; + + // Actually execute the tool via RPC to the plugin + const result = await executeVaultTool(tool.name, tool.input, bridge); + + yield { + type: 'tool_end', + name: tool.name, + result: result.content, + }; + + await sleep(100); + } + + // Stream follow-up response after tools + if (scenario.followUp) { + yield { type: 'text_delta', text: '\n\n' }; + for await (const chunk of streamText(scenario.followUp, signal)) { + if (signal?.aborted) { + yield { type: 'complete', result: 'Cancelled by user' }; + return; + } + yield { type: 'text_delta', text: chunk }; + } + } + } + + // Complete + const finalText = scenario.followUp + ? `${scenario.response}\n\n${scenario.followUp}` + : scenario.response; + yield { type: 'complete', result: finalText }; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index baab2027..781eb205 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,7 +9,10 @@ import { WebSocketServer, WebSocket } from 'ws'; import { randomUUID } from 'crypto'; import type { IncomingMessage } from 'http'; import { runAgent } from './agent.js'; +import { runMockAgent } from './mock-agent.js'; import { logger } from './utils.js'; + +const MOCK_MODE = process.env.MOCK_MODE === 'true'; import type { ClientMessage, ServerMessage, @@ -98,7 +101,10 @@ class ConnectionHandler { // Create vault bridge that sends RPC requests to plugin const vaultBridge = this.createVaultBridge(); - for await (const event of runAgent( + // Use mock agent in mock mode, real agent otherwise + const agentRunner = MOCK_MODE ? runMockAgent : runAgent; + + for await (const event of agentRunner( msg.prompt, vaultBridge, msg.context, diff --git a/backend/test/automated-test.ts b/backend/test/automated-test.ts new file mode 100644 index 00000000..40c1fcfb --- /dev/null +++ b/backend/test/automated-test.ts @@ -0,0 +1,347 @@ +/** + * Automated Test for Backend Server + * + * Tests the WebSocket protocol and mock agent without user interaction. + */ + +import WebSocket from 'ws'; +import { randomUUID } from 'crypto'; + +const SERVER_URL = process.env.SERVER_URL || 'ws://localhost:3001'; +const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token'; + +// Mock vault data +const mockVault: Record = { + 'welcome.md': '# Welcome\n\nTest note content.', + 'projects/test.md': '# Test Project\n\nSome project info.', +}; + +const mockFolders: Record> = { + '': [ + { name: 'welcome.md', type: 'file' }, + { name: 'projects', type: 'folder' }, + ], + 'projects': [ + { name: 'test.md', type: 'file' }, + ], +}; + +interface TestResult { + name: string; + passed: boolean; + error?: string; + duration: number; +} + +const results: TestResult[] = []; + +function log(msg: string) { + console.log(`[TEST] ${msg}`); +} + +function logSuccess(msg: string) { + console.log(`\x1b[32m✓\x1b[0m ${msg}`); +} + +function logError(msg: string) { + console.log(`\x1b[31m✗\x1b[0m ${msg}`); +} + +function handleRpcRequest(ws: WebSocket, request: { id: string; method: string; params: Record }) { + let result: unknown; + let error: { code: string; message: string } | undefined; + + switch (request.method) { + case 'vault_read': { + const path = request.params.path as string; + const content = mockVault[path]; + if (content) { + result = { content }; + } else { + error = { code: 'NOT_FOUND', message: `File not found: ${path}` }; + } + break; + } + case 'vault_write': { + const path = request.params.path as string; + const content = request.params.content as string; + mockVault[path] = content; + result = { success: true }; + break; + } + case 'vault_search': { + const query = (request.params.query as string).toLowerCase(); + const searchResults: Array<{ path: string; snippet: string }> = []; + for (const [path, content] of Object.entries(mockVault)) { + if (path.toLowerCase().includes(query) || content.toLowerCase().includes(query)) { + searchResults.push({ path, snippet: content.substring(0, 50) }); + } + } + result = searchResults; + break; + } + case 'vault_list': { + const folder = (request.params.folder as string) || ''; + const items = mockFolders[folder]; + if (items) { + result = items.map(item => ({ + name: item.name, + path: folder ? `${folder}/${item.name}` : item.name, + type: item.type, + })); + } else { + error = { code: 'NOT_FOUND', message: `Folder not found: ${folder}` }; + } + break; + } + case 'vault_delete': { + const path = request.params.path as string; + if (mockVault[path]) { + delete mockVault[path]; + result = { success: true }; + } else { + error = { code: 'NOT_FOUND', message: `File not found: ${path}` }; + } + break; + } + default: + error = { code: 'UNKNOWN', message: `Unknown method: ${request.method}` }; + } + + ws.send(JSON.stringify({ + type: 'rpc_response', + id: request.id, + ...(error ? { error } : { result }), + })); +} + +async function runTest( + name: string, + ws: WebSocket, + prompt: string, + expectations: { + shouldReceiveTextDelta?: boolean; + shouldUseTool?: string; + shouldComplete?: boolean; + timeout?: number; + } +): Promise { + const startTime = Date.now(); + const timeout = expectations.timeout || 10000; + + return new Promise((resolve) => { + const requestId = randomUUID(); + let receivedTextDelta = false; + let usedTools: string[] = []; + let completed = false; + let errorMsg: string | undefined; + + const timeoutId = setTimeout(() => { + cleanup(); + resolve({ + name, + passed: false, + error: 'Test timed out', + duration: Date.now() - startTime, + }); + }, timeout); + + const messageHandler = (data: WebSocket.RawData) => { + const msg = JSON.parse(data.toString()); + + if (msg.requestId && msg.requestId !== requestId) return; + + switch (msg.type) { + case 'text_delta': + receivedTextDelta = true; + break; + case 'tool_start': + usedTools.push(msg.toolName); + break; + case 'rpc_request': + handleRpcRequest(ws, msg); + break; + case 'complete': + completed = true; + cleanup(); + checkExpectations(); + break; + case 'error': + errorMsg = `${msg.code}: ${msg.message}`; + cleanup(); + resolve({ + name, + passed: false, + error: errorMsg, + duration: Date.now() - startTime, + }); + break; + } + }; + + const cleanup = () => { + clearTimeout(timeoutId); + ws.off('message', messageHandler); + }; + + const checkExpectations = () => { + const errors: string[] = []; + + if (expectations.shouldReceiveTextDelta && !receivedTextDelta) { + errors.push('Expected text_delta but none received'); + } + + if (expectations.shouldUseTool && !usedTools.includes(expectations.shouldUseTool)) { + errors.push(`Expected tool ${expectations.shouldUseTool} but got [${usedTools.join(', ')}]`); + } + + if (expectations.shouldComplete && !completed) { + errors.push('Expected completion but not received'); + } + + resolve({ + name, + passed: errors.length === 0, + error: errors.length > 0 ? errors.join('; ') : undefined, + duration: Date.now() - startTime, + }); + }; + + ws.on('message', messageHandler); + + // Send the prompt + ws.send(JSON.stringify({ + type: 'prompt', + id: requestId, + prompt, + })); + }); +} + +async function main() { + log('Connecting to server...'); + + const wsUrl = `${SERVER_URL}?token=${encodeURIComponent(AUTH_TOKEN)}`; + const ws = new WebSocket(wsUrl); + + await new Promise((resolve, reject) => { + ws.on('open', () => { + log('Connected to server'); + resolve(); + }); + ws.on('error', reject); + }); + + // Test 1: Simple prompt (no tools) + log('Running Test 1: Simple prompt response...'); + const test1 = await runTest( + 'Simple prompt response', + ws, + 'Hello, how are you?', + { shouldReceiveTextDelta: true, shouldComplete: true } + ); + results.push(test1); + test1.passed ? logSuccess(test1.name) : logError(`${test1.name}: ${test1.error}`); + + // Test 2: List files (uses vault_list) + log('Running Test 2: List files tool...'); + const test2 = await runTest( + 'List files tool', + ws, + 'list files in my vault', + { shouldReceiveTextDelta: true, shouldUseTool: 'vault_list', shouldComplete: true } + ); + results.push(test2); + test2.passed ? logSuccess(test2.name) : logError(`${test2.name}: ${test2.error}`); + + // Test 3: Search vault (uses vault_search) + log('Running Test 3: Search vault tool...'); + const test3 = await runTest( + 'Search vault tool', + ws, + 'search for "project"', + { shouldReceiveTextDelta: true, shouldUseTool: 'vault_search', shouldComplete: true } + ); + results.push(test3); + test3.passed ? logSuccess(test3.name) : logError(`${test3.name}: ${test3.error}`); + + // Test 4: Read file (uses vault_read) + log('Running Test 4: Read file tool...'); + const test4 = await runTest( + 'Read file tool', + ws, + 'read welcome.md', + { shouldReceiveTextDelta: true, shouldUseTool: 'vault_read', shouldComplete: true } + ); + results.push(test4); + test4.passed ? logSuccess(test4.name) : logError(`${test4.name}: ${test4.error}`); + + // Test 5: Create file (uses vault_write) + log('Running Test 5: Create file tool...'); + const test5 = await runTest( + 'Create file tool', + ws, + 'create new-note.md with content "Hello World"', + { shouldReceiveTextDelta: true, shouldUseTool: 'vault_write', shouldComplete: true } + ); + results.push(test5); + test5.passed ? logSuccess(test5.name) : logError(`${test5.name}: ${test5.error}`); + + // Verify the file was created + if (mockVault['new-note.md']) { + logSuccess('File was created in mock vault'); + } else { + logError('File was NOT created in mock vault'); + } + + // Test 6: Ping/Pong + log('Running Test 6: Ping/Pong...'); + const pingTest = await new Promise((resolve) => { + const startTime = Date.now(); + const timeoutId = setTimeout(() => { + resolve({ name: 'Ping/Pong', passed: false, error: 'No pong received', duration: Date.now() - startTime }); + }, 5000); + + const handler = (data: WebSocket.RawData) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'pong') { + clearTimeout(timeoutId); + ws.off('message', handler); + resolve({ name: 'Ping/Pong', passed: true, duration: Date.now() - startTime }); + } + }; + ws.on('message', handler); + ws.send(JSON.stringify({ type: 'ping' })); + }); + results.push(pingTest); + pingTest.passed ? logSuccess(pingTest.name) : logError(`${pingTest.name}: ${pingTest.error}`); + + // Close connection + ws.close(); + + // Print summary + console.log('\n' + '='.repeat(50)); + console.log('TEST SUMMARY'); + console.log('='.repeat(50)); + + const passed = results.filter(r => r.passed).length; + const failed = results.filter(r => !r.passed).length; + + console.log(`Passed: ${passed}/${results.length}`); + console.log(`Failed: ${failed}/${results.length}`); + console.log(`Total time: ${results.reduce((sum, r) => sum + r.duration, 0)}ms`); + + if (failed > 0) { + console.log('\nFailed tests:'); + for (const result of results.filter(r => !r.passed)) { + console.log(` - ${result.name}: ${result.error}`); + } + } + + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/backend/test/test-client.ts b/backend/test/test-client.ts new file mode 100644 index 00000000..a7ebd9cf --- /dev/null +++ b/backend/test/test-client.ts @@ -0,0 +1,369 @@ +/** + * Test WebSocket Client + * + * Simulates the Obsidian plugin to test the backend server. + * Connects to the server, sends prompts, and responds to RPC requests. + */ + +import WebSocket from 'ws'; +import { randomUUID } from 'crypto'; +import * as readline from 'readline'; + +const SERVER_URL = process.env.SERVER_URL || 'ws://localhost:3001'; +const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token'; + +// ANSI color codes for pretty output +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', +}; + +function log(prefix: string, color: string, message: string) { + console.log(`${color}[${prefix}]${colors.reset} ${message}`); +} + +// Mock vault data for testing +const mockVault: Record = { + 'welcome.md': `# Welcome to My Vault + +This is a test note in the mock vault. + +## Links +- [[daily-notes/2024-01-01]] +- [[projects/project-alpha]] + +#welcome #test +`, + 'daily-notes/2024-01-01.md': `# Daily Note - 2024-01-01 + +## Tasks +- [x] Review project specs +- [ ] Write documentation +- [ ] Send follow-up emails + +## Notes +Had a productive meeting about the new feature. + +#daily #january +`, + 'projects/project-alpha.md': `# Project Alpha + +## Overview +A groundbreaking project that will change everything. + +## Status +- Phase 1: Complete +- Phase 2: In Progress +- Phase 3: Planning + +## Team +- Lead: Alice +- Dev: Bob, Carol + +#project #active +`, +}; + +// Mock folder structure +const mockFolders: Record> = { + '': [ + { name: 'welcome.md', type: 'file' }, + { name: 'daily-notes', type: 'folder' }, + { name: 'projects', type: 'folder' }, + ], + 'daily-notes': [ + { name: '2024-01-01.md', type: 'file' }, + ], + 'projects': [ + { name: 'project-alpha.md', type: 'file' }, + ], +}; + +interface RpcRequest { + type: 'rpc_request'; + id: string; + method: string; + params: Record; +} + +function handleRpcRequest(ws: WebSocket, request: RpcRequest) { + log('RPC', colors.yellow, `${request.method}(${JSON.stringify(request.params)})`); + + let result: unknown; + let error: { code: string; message: string } | undefined; + + try { + switch (request.method) { + case 'vault_read': { + const path = request.params.path as string; + const content = mockVault[path]; + if (content) { + result = { content }; + log('RPC', colors.green, `Read ${path} (${content.length} chars)`); + } else { + error = { code: 'NOT_FOUND', message: `File not found: ${path}` }; + log('RPC', colors.red, `File not found: ${path}`); + } + break; + } + + case 'vault_write': { + const path = request.params.path as string; + const content = request.params.content as string; + mockVault[path] = content; + result = { success: true }; + log('RPC', colors.green, `Wrote ${path} (${content.length} chars)`); + break; + } + + case 'vault_search': { + const query = (request.params.query as string).toLowerCase(); + const limit = (request.params.limit as number) || 20; + const results: Array<{ path: string; snippet: string }> = []; + + for (const [path, content] of Object.entries(mockVault)) { + if (results.length >= limit) break; + + if (path.toLowerCase().includes(query)) { + results.push({ path, snippet: `Filename match: ${path}` }); + } else if (content.toLowerCase().includes(query)) { + const idx = content.toLowerCase().indexOf(query); + const start = Math.max(0, idx - 30); + const end = Math.min(content.length, idx + query.length + 30); + const snippet = content.substring(start, end); + results.push({ path, snippet: `...${snippet}...` }); + } + } + + result = results; + log('RPC', colors.green, `Search "${query}" found ${results.length} results`); + break; + } + + case 'vault_list': { + const folder = (request.params.folder as string) || ''; + const items = mockFolders[folder]; + if (items) { + result = items.map(item => ({ + name: item.name, + path: folder ? `${folder}/${item.name}` : item.name, + type: item.type, + })); + log('RPC', colors.green, `Listed ${folder || 'root'}: ${items.length} items`); + } else { + error = { code: 'NOT_FOUND', message: `Folder not found: ${folder}` }; + log('RPC', colors.red, `Folder not found: ${folder}`); + } + break; + } + + case 'vault_delete': { + const path = request.params.path as string; + if (mockVault[path]) { + delete mockVault[path]; + result = { success: true }; + log('RPC', colors.green, `Deleted ${path}`); + } else { + error = { code: 'NOT_FOUND', message: `File not found: ${path}` }; + log('RPC', colors.red, `File not found: ${path}`); + } + break; + } + + default: + error = { code: 'UNKNOWN_METHOD', message: `Unknown method: ${request.method}` }; + log('RPC', colors.red, `Unknown method: ${request.method}`); + } + } catch (err) { + error = { + code: 'ERROR', + message: err instanceof Error ? err.message : 'Unknown error', + }; + log('RPC', colors.red, `Error: ${error.message}`); + } + + // Send response + const response = { + type: 'rpc_response', + id: request.id, + ...(error ? { error } : { result }), + }; + + ws.send(JSON.stringify(response)); +} + +function runTestClient() { + const wsUrl = `${SERVER_URL}?token=${encodeURIComponent(AUTH_TOKEN)}`; + log('CLIENT', colors.cyan, `Connecting to ${SERVER_URL}...`); + + const ws = new WebSocket(wsUrl); + let currentRequestId: string | null = null; + let responseBuffer = ''; + + ws.on('open', () => { + log('CLIENT', colors.green, 'Connected!'); + log('CLIENT', colors.dim, 'Type a message and press Enter to send. Type "quit" to exit.'); + log('CLIENT', colors.dim, 'Try: "list files", "search project", "read welcome.md", "create test.md"'); + console.log(''); + startRepl(); + }); + + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + + switch (msg.type) { + case 'text_delta': + process.stdout.write(`${colors.bright}${msg.text}${colors.reset}`); + responseBuffer += msg.text; + break; + + case 'tool_start': + console.log(''); + log('TOOL', colors.magenta, `▶ ${msg.toolName}(${JSON.stringify(msg.toolInput)})`); + break; + + case 'tool_end': + log('TOOL', colors.magenta, `◀ ${msg.toolName}: ${msg.result.substring(0, 100)}${msg.result.length > 100 ? '...' : ''}`); + break; + + case 'thinking': + log('THINK', colors.dim, msg.text); + break; + + case 'complete': + console.log(''); + log('DONE', colors.green, `Request completed`); + currentRequestId = null; + responseBuffer = ''; + break; + + case 'error': + console.log(''); + log('ERROR', colors.red, `${msg.code}: ${msg.message}`); + currentRequestId = null; + responseBuffer = ''; + break; + + case 'rpc_request': + handleRpcRequest(ws, msg); + break; + + case 'pong': + // Ignore pong + break; + + default: + log('MSG', colors.dim, JSON.stringify(msg)); + } + }); + + ws.on('close', (code, reason) => { + log('CLIENT', colors.yellow, `Disconnected: ${code} ${reason}`); + process.exit(0); + }); + + ws.on('error', (err) => { + log('CLIENT', colors.red, `Error: ${err.message}`); + process.exit(1); + }); + + // Interactive REPL + function startRepl() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: `${colors.cyan}> ${colors.reset}`, + }); + + rl.prompt(); + + rl.on('line', (line) => { + const input = line.trim(); + + if (!input) { + rl.prompt(); + return; + } + + if (input.toLowerCase() === 'quit' || input.toLowerCase() === 'exit') { + log('CLIENT', colors.yellow, 'Goodbye!'); + ws.close(); + rl.close(); + return; + } + + if (input.toLowerCase() === 'cancel' && currentRequestId) { + log('CLIENT', colors.yellow, 'Cancelling request...'); + ws.send(JSON.stringify({ type: 'cancel', id: currentRequestId })); + rl.prompt(); + return; + } + + if (input.toLowerCase() === 'ping') { + ws.send(JSON.stringify({ type: 'ping' })); + rl.prompt(); + return; + } + + if (input.toLowerCase() === 'vault') { + log('VAULT', colors.cyan, 'Current mock vault contents:'); + for (const [path, content] of Object.entries(mockVault)) { + console.log(` ${colors.dim}${path}${colors.reset} (${content.length} chars)`); + } + rl.prompt(); + return; + } + + // Send prompt + currentRequestId = randomUUID(); + const message = { + type: 'prompt', + id: currentRequestId, + prompt: input, + context: { + currentFile: 'welcome.md', // Simulate having a file open + }, + }; + + log('SEND', colors.blue, `Prompt: "${input}"`); + console.log(''); + ws.send(JSON.stringify(message)); + + // Don't prompt until response is complete + rl.on('close', () => { + // Cleanup + }); + }); + + // Re-prompt after complete messages + const originalPrompt = rl.prompt.bind(rl); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'complete' || msg.type === 'error') { + console.log(''); + originalPrompt(); + } + }); + } + + // Ping interval + const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 25000); + + ws.on('close', () => { + clearInterval(pingInterval); + }); +} + +// Run the client +runTestClient(); From 12e5d42b26fa5d2fa488e4ccd68bbd2838b4de14 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Jan 2026 22:39:06 +0000 Subject: [PATCH 03/40] Fix Railway deployment configuration - Add railway.toml at repo root to point to backend/Dockerfile - Update Dockerfile to build from repo root context - Build TypeScript inside container instead of expecting pre-built dist/ --- backend/Dockerfile | 21 ++++++++++++++------- backend/railway.json | 13 +++++++++++++ railway.toml | 10 ++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 backend/railway.json create mode 100644 railway.toml diff --git a/backend/Dockerfile b/backend/Dockerfile index d60be897..c3fbd4a6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,14 +2,21 @@ FROM node:22-slim WORKDIR /app -# Copy package files -COPY package*.json ./ +# Copy package files from backend directory +COPY backend/package*.json ./ -# Install production dependencies -RUN npm ci --only=production +# Install all dependencies (need devDeps for build) +RUN npm ci -# Copy built files -COPY dist ./dist +# Copy source files +COPY backend/src ./src +COPY backend/tsconfig.json ./ + +# Build TypeScript +RUN npm run build + +# Remove dev dependencies +RUN npm prune --production # Set environment ENV NODE_ENV=production @@ -18,7 +25,7 @@ ENV PORT=3001 # Expose port EXPOSE 3001 -# Health check +# Health check - WebSocket upgrade returns 426 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node -e "const http = require('http'); const req = http.request({hostname: 'localhost', port: process.env.PORT || 3001, path: '/', method: 'GET', timeout: 2000}, (res) => process.exit(res.statusCode === 426 ? 0 : 1)); req.on('error', () => process.exit(1)); req.end();" || exit 1 diff --git a/backend/railway.json b/backend/railway.json new file mode 100644 index 00000000..e840ae95 --- /dev/null +++ b/backend/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" + }, + "deploy": { + "healthcheckPath": "/", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} diff --git a/railway.toml b/railway.toml new file mode 100644 index 00000000..ec5b4670 --- /dev/null +++ b/railway.toml @@ -0,0 +1,10 @@ +[build] +builder = "dockerfile" +dockerfilePath = "backend/Dockerfile" +watchPatterns = ["backend/**"] + +[deploy] +healthcheckPath = "/" +healthcheckTimeout = 30 +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 3 From 9f55a5032eac44ec9c2574d1f992917fc7b36c85 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Jan 2026 22:58:09 +0000 Subject: [PATCH 04/40] Add HTTP health check endpoint - Add /health and / endpoints that return JSON status - Attach WebSocket server to HTTP server - Update Dockerfile health check to use /health endpoint --- backend/Dockerfile | 4 ++-- backend/src/server.ts | 32 ++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index c3fbd4a6..bfb0bb8f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,9 +25,9 @@ ENV PORT=3001 # Expose port EXPOSE 3001 -# Health check - WebSocket upgrade returns 426 +# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "const http = require('http'); const req = http.request({hostname: 'localhost', port: process.env.PORT || 3001, path: '/', method: 'GET', timeout: 2000}, (res) => process.exit(res.statusCode === 426 ? 0 : 1)); req.on('error', () => process.exit(1)); req.end();" || exit 1 + CMD node -e "const http = require('http'); http.get('http://localhost:' + (process.env.PORT || 3001) + '/health', (res) => process.exit(res.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1));" || exit 1 # Run the server CMD ["node", "dist/index.js"] diff --git a/backend/src/server.ts b/backend/src/server.ts index 781eb205..fb5f521d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,8 +6,9 @@ */ import { WebSocketServer, WebSocket } from 'ws'; +import { createServer, type Server } from 'http'; import { randomUUID } from 'crypto'; -import type { IncomingMessage } from 'http'; +import type { IncomingMessage, ServerResponse } from 'http'; import { runAgent } from './agent.js'; import { runMockAgent } from './mock-agent.js'; import { logger } from './utils.js'; @@ -282,12 +283,28 @@ class ConnectionHandler { } /** - * Start the WebSocket server + * Start the HTTP + WebSocket server */ -export function startServer(): WebSocketServer { - const wss = new WebSocketServer({ port: PORT }); +export function startServer(): Server { const connections = new Map(); + // Create HTTP server for health checks + const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { + // Health check endpoint + if (req.url === '/health' || req.url === '/') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', mock: MOCK_MODE })); + return; + } + + // 404 for other routes + res.writeHead(404); + res.end('Not Found'); + }); + + // Create WebSocket server attached to HTTP server + const wss = new WebSocketServer({ server: httpServer }); + // Heartbeat interval to detect dead connections const heartbeatInterval = setInterval(() => { for (const [ws, handler] of connections) { @@ -322,6 +339,9 @@ export function startServer(): WebSocketServer { clearInterval(heartbeatInterval); }); - logger.info(`WebSocket server running on port ${PORT}`); - return wss; + httpServer.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); + }); + + return httpServer; } From 6058004686ed522c68ba781fe0b96f36bd6f80f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Jan 2026 23:11:23 +0000 Subject: [PATCH 05/40] Fix Docker build context for Railway - Set buildContext = 'backend' in railway.toml - Fix Dockerfile COPY paths to be relative to backend dir - Update healthcheckPath to /health --- backend/Dockerfile | 8 ++++---- railway.toml | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index bfb0bb8f..334af51a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,15 +2,15 @@ FROM node:22-slim WORKDIR /app -# Copy package files from backend directory -COPY backend/package*.json ./ +# Copy package files +COPY package*.json ./ # Install all dependencies (need devDeps for build) RUN npm ci # Copy source files -COPY backend/src ./src -COPY backend/tsconfig.json ./ +COPY src ./src +COPY tsconfig.json ./ # Build TypeScript RUN npm run build diff --git a/railway.toml b/railway.toml index ec5b4670..09d9bacb 100644 --- a/railway.toml +++ b/railway.toml @@ -1,10 +1,11 @@ [build] builder = "dockerfile" dockerfilePath = "backend/Dockerfile" +buildContext = "backend" watchPatterns = ["backend/**"] [deploy] -healthcheckPath = "/" +healthcheckPath = "/health" healthcheckTimeout = 30 restartPolicyType = "on_failure" restartPolicyMaxRetries = 3 From c33425d2b437665c2196348237f10d0fd1e9f870 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:22:58 -0500 Subject: [PATCH 06/40] Add vault_edit, vault_grep, vault_glob, vault_rename tools Implements additional Claude Agent SDK-like tools for more powerful vault operations: - vault_edit: Precise string replacement in files - vault_grep: Regex search across file contents with line numbers - vault_glob: File pattern matching with glob syntax - vault_rename: Move/rename files with link updates Also adds UI formatting for new tool results with clickable wikilinks. Co-Authored-By: Claude Opus 4.5 --- backend/src/mcp-tools.ts | 140 ++++++++ backend/src/protocol.ts | 13 +- backend/src/server.ts | 23 ++ src/core/backend/VaultRpcHandler.ts | 413 ++++++++++++++++++++++ src/core/backend/protocol.ts | 155 ++++++++ src/core/backend/tool-result-formatter.ts | 146 ++++++++ 6 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 src/core/backend/VaultRpcHandler.ts create mode 100644 src/core/backend/protocol.ts create mode 100644 src/core/backend/tool-result-formatter.ts diff --git a/backend/src/mcp-tools.ts b/backend/src/mcp-tools.ts index 7e4c424e..3014dfb9 100644 --- a/backend/src/mcp-tools.ts +++ b/backend/src/mcp-tools.ts @@ -69,6 +69,29 @@ export function getVaultToolDefinitions(): ToolDefinition[] { required: ['path', 'content'], }, }, + { + name: 'vault_edit', + description: + 'Make precise edits to an existing note by replacing a specific string. More efficient than rewriting entire file. The old_string must match exactly (including whitespace).', + input_schema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path to the note to edit', + }, + old_string: { + type: 'string', + description: 'Exact text to find and replace (must be unique in file)', + }, + new_string: { + type: 'string', + description: 'Text to replace it with', + }, + }, + required: ['path', 'old_string', 'new_string'], + }, + }, { name: 'vault_search', description: @@ -88,6 +111,48 @@ export function getVaultToolDefinitions(): ToolDefinition[] { required: ['query'], }, }, + { + name: 'vault_grep', + description: + 'Search file contents using a regex pattern. More powerful than vault_search for pattern matching. Returns matching lines with context.', + input_schema: { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'Regular expression pattern to search for', + }, + folder: { + type: 'string', + description: 'Folder to search in (empty for entire vault)', + }, + file_pattern: { + type: 'string', + description: 'Glob pattern to filter files, e.g. "*.md" (default: all markdown files)', + }, + limit: { + type: 'number', + description: 'Maximum results to return (default: 50)', + }, + }, + required: ['pattern'], + }, + }, + { + name: 'vault_glob', + description: + 'Find files matching a glob pattern. Use this to discover files by name pattern, e.g. "**/*.md" for all markdown files, "projects/*.md" for markdown in projects folder.', + input_schema: { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'Glob pattern, e.g. "**/*.md", "daily/*.md", "projects/**/*"', + }, + }, + required: ['pattern'], + }, + }, { name: 'vault_list', description: @@ -103,6 +168,25 @@ export function getVaultToolDefinitions(): ToolDefinition[] { required: [], }, }, + { + name: 'vault_rename', + description: + 'Rename or move a note to a new path. Updates any internal links pointing to this file if possible.', + input_schema: { + type: 'object', + properties: { + old_path: { + type: 'string', + description: 'Current path of the note', + }, + new_path: { + type: 'string', + description: 'New path for the note', + }, + }, + required: ['old_path', 'new_path'], + }, + }, { name: 'vault_delete', description: @@ -197,6 +281,62 @@ export async function executeVaultTool( }; } + case 'vault_edit': { + const path = input.path as string; + const oldString = input.old_string as string; + const newString = input.new_string as string; + await bridge.edit(path, oldString, newString); + return { + content: `Successfully edited ${path}`, + }; + } + + case 'vault_grep': { + const pattern = input.pattern as string; + const folder = (input.folder as string) || ''; + const filePattern = (input.file_pattern as string) || '*.md'; + const limit = (input.limit as number) || 50; + const results = await bridge.grep(pattern, folder, filePattern, limit); + + if (results.length === 0) { + return { + content: 'No matches found.', + }; + } + + const formatted = results + .map((r) => `${r.path}:${r.line}: ${truncate(r.content, 100)}`) + .join('\n'); + return { + content: `Found ${results.length} match(es):\n${formatted}`, + }; + } + + case 'vault_glob': { + const pattern = input.pattern as string; + const files = await bridge.glob(pattern); + + if (files.length === 0) { + return { + content: 'No files matched the pattern.', + }; + } + + const formatted = files.map((f) => `- ${f}`).join('\n'); + return { + content: `Found ${files.length} file(s):\n${formatted}`, + }; + } + + case 'vault_rename': { + const oldPath = input.old_path as string; + const newPath = input.new_path as string; + await bridge.rename(oldPath, newPath); + return { + content: `Renamed ${oldPath} → ${newPath}`, + }; + } + default: return { content: `Unknown tool: ${toolName}`, diff --git a/backend/src/protocol.ts b/backend/src/protocol.ts index 7ec72c40..1f6d5604 100644 --- a/backend/src/protocol.ts +++ b/backend/src/protocol.ts @@ -119,7 +119,7 @@ export interface ErrorMessage { export interface RpcRequestMessage { type: 'rpc_request'; id: string; - method: 'vault_read' | 'vault_write' | 'vault_search' | 'vault_list' | 'vault_delete'; + method: 'vault_read' | 'vault_write' | 'vault_edit' | 'vault_search' | 'vault_grep' | 'vault_glob' | 'vault_list' | 'vault_rename' | 'vault_delete'; params: Record; } @@ -199,10 +199,21 @@ export type AgentEvent = // Vault Bridge Interface // ============================================================================ +export interface GrepResult { + path: string; + line: number; + content: string; + context?: string; +} + export interface VaultBridge { read(path: string): Promise; write(path: string, content: string): Promise; + edit(path: string, oldString: string, newString: string): Promise; search(query: string, limit?: number): Promise; + grep(pattern: string, folder?: string, filePattern?: string, limit?: number): Promise; + glob(pattern: string): Promise; list(folder: string): Promise; + rename(oldPath: string, newPath: string): Promise; delete(path: string): Promise; } diff --git a/backend/src/server.ts b/backend/src/server.ts index fb5f521d..f6159724 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -23,6 +23,7 @@ import type { VaultBridge, SearchResult, FileInfo, + GrepResult, } from './protocol.js'; const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token'; @@ -243,15 +244,37 @@ class ConnectionHandler { write: async (path: string, content: string): Promise => { await this.sendRpc('vault_write', { path, content }); }, + edit: async (path: string, oldString: string, newString: string): Promise => { + await this.sendRpc('vault_edit', { path, old_string: oldString, new_string: newString }); + }, search: async ( query: string, limit: number = 20 ): Promise => { return this.sendRpc('vault_search', { query, limit }); }, + grep: async ( + pattern: string, + folder?: string, + filePattern?: string, + limit: number = 50 + ): Promise => { + return this.sendRpc('vault_grep', { + pattern, + folder: folder || '', + file_pattern: filePattern || '*.md', + limit + }); + }, + glob: async (pattern: string): Promise => { + return this.sendRpc('vault_glob', { pattern }); + }, list: async (folder: string): Promise => { return this.sendRpc('vault_list', { folder }); }, + rename: async (oldPath: string, newPath: string): Promise => { + await this.sendRpc('vault_rename', { old_path: oldPath, new_path: newPath }); + }, delete: async (path: string): Promise => { await this.sendRpc('vault_delete', { path }); }, diff --git a/src/core/backend/VaultRpcHandler.ts b/src/core/backend/VaultRpcHandler.ts new file mode 100644 index 00000000..065ad24c --- /dev/null +++ b/src/core/backend/VaultRpcHandler.ts @@ -0,0 +1,413 @@ +/** + * Vault RPC Handler + * + * Handles RPC requests from the backend for vault operations. + * Uses Obsidian's API to read, write, search, list, and delete files. + */ + +import { App, TFile, TFolder, Notice } from 'obsidian'; +import type { SearchResult, FileInfo, GrepResult } from './protocol'; + +export class VaultRpcHandler { + constructor(private app: App) {} + + /** + * Handle an RPC request from the backend + */ + async handleRpc( + method: string, + params: Record + ): Promise { + switch (method) { + case 'vault_read': + return this.vaultRead(params.path as string); + case 'vault_write': + return this.vaultWrite( + params.path as string, + params.content as string + ); + case 'vault_edit': + return this.vaultEdit( + params.path as string, + params.old_string as string, + params.new_string as string + ); + case 'vault_search': + return this.vaultSearch( + params.query as string, + (params.limit as number) || 20 + ); + case 'vault_grep': + return this.vaultGrep( + params.pattern as string, + (params.folder as string) || '', + (params.file_pattern as string) || '*.md', + (params.limit as number) || 50 + ); + case 'vault_glob': + return this.vaultGlob(params.pattern as string); + case 'vault_list': + return this.vaultList((params.folder as string) || ''); + case 'vault_rename': + return this.vaultRename( + params.old_path as string, + params.new_path as string + ); + case 'vault_delete': + return this.vaultDelete(params.path as string); + default: + throw new Error(`Unknown RPC method: ${method}`); + } + } + + /** + * Read file content from vault + */ + private async vaultRead(path: string): Promise<{ content: string }> { + const file = this.app.vault.getAbstractFileByPath(path); + + if (!file) { + throw new Error(`File not found: ${path}`); + } + + if (!(file instanceof TFile)) { + throw new Error(`Path is not a file: ${path}`); + } + + const content = await this.app.vault.cachedRead(file); + + return { content }; + } + + /** + * Write content to a file (create or overwrite) + */ + private async vaultWrite( + path: string, + content: string + ): Promise<{ success: boolean }> { + const existingFile = this.app.vault.getAbstractFileByPath(path); + + if (existingFile instanceof TFile) { + // File exists, modify it + await this.app.vault.modify(existingFile, content); + } else if (existingFile instanceof TFolder) { + throw new Error(`Path is a folder, not a file: ${path}`); + } else { + // File doesn't exist, create it + // First, ensure parent folders exist + const folderPath = path.substring(0, path.lastIndexOf('/')); + if (folderPath) { + await this.ensureFolderExists(folderPath); + } + + await this.app.vault.create(path, content); + } + + return { success: true }; + } + + /** + * Edit a file by replacing a specific string + */ + private async vaultEdit( + path: string, + oldString: string, + newString: string + ): Promise<{ success: boolean }> { + const file = this.app.vault.getAbstractFileByPath(path); + + if (!file) { + throw new Error(`File not found: ${path}`); + } + + if (!(file instanceof TFile)) { + throw new Error(`Path is not a file: ${path}`); + } + + const content = await this.app.vault.read(file); + + // Check if old_string exists in file + if (!content.includes(oldString)) { + throw new Error( + `String not found in file. Make sure old_string matches exactly (including whitespace).` + ); + } + + // Check if old_string is unique + const occurrences = content.split(oldString).length - 1; + if (occurrences > 1) { + throw new Error( + `String appears ${occurrences} times in file. Provide more context to make it unique.` + ); + } + + // Replace the string + const newContent = content.replace(oldString, newString); + await this.app.vault.modify(file, newContent); + + return { success: true }; + } + + /** + * Search for files matching a query + */ + private async vaultSearch( + query: string, + limit: number + ): Promise { + const results: SearchResult[] = []; + const files = this.app.vault.getMarkdownFiles(); + const queryLower = query.toLowerCase(); + + for (const file of files) { + if (results.length >= limit) break; + + // Check filename first + if (file.path.toLowerCase().includes(queryLower)) { + results.push({ + path: file.path, + snippet: `Filename match: ${file.basename}`, + }); + continue; + } + + // Check file content + try { + const content = await this.app.vault.cachedRead(file); + const contentLower = content.toLowerCase(); + const index = contentLower.indexOf(queryLower); + + if (index !== -1) { + // Extract snippet around match + const start = Math.max(0, index - 50); + const end = Math.min( + content.length, + index + query.length + 50 + ); + let snippet = content.substring(start, end); + + // Add ellipsis + if (start > 0) snippet = '...' + snippet; + if (end < content.length) snippet = snippet + '...'; + + results.push({ path: file.path, snippet }); + } + } catch (error) { + console.error( + `[VaultRpcHandler] Error reading file ${file.path}:`, + error + ); + } + } + + return results; + } + + /** + * Search file contents using a regex pattern + */ + private async vaultGrep( + pattern: string, + folder: string, + filePattern: string, + limit: number + ): Promise { + const results: GrepResult[] = []; + const files = this.app.vault.getMarkdownFiles(); + + // Compile regex + let regex: RegExp; + try { + regex = new RegExp(pattern, 'gi'); + } catch (e) { + throw new Error(`Invalid regex pattern: ${pattern}`); + } + + // Convert glob pattern to regex for file matching + const fileRegex = this.globToRegex(filePattern); + + for (const file of files) { + if (results.length >= limit) break; + + // Check folder filter + if (folder && !file.path.startsWith(folder)) { + continue; + } + + // Check file pattern + if (!fileRegex.test(file.name)) { + continue; + } + + try { + const content = await this.app.vault.cachedRead(file); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length && results.length < limit; i++) { + const line = lines[i]; + if (regex.test(line)) { + // Reset lastIndex after test + regex.lastIndex = 0; + results.push({ + path: file.path, + line: i + 1, + content: line.trim(), + }); + } + } + } catch (error) { + console.error( + `[VaultRpcHandler] Error reading file ${file.path}:`, + error + ); + } + } + + return results; + } + + /** + * Find files matching a glob pattern + */ + private async vaultGlob(pattern: string): Promise { + const files = this.app.vault.getFiles(); + const regex = this.globToRegex(pattern); + const matches: string[] = []; + + for (const file of files) { + if (regex.test(file.path)) { + matches.push(file.path); + } + } + + // Sort alphabetically + matches.sort((a, b) => a.localeCompare(b)); + + return matches; + } + + /** + * Convert a glob pattern to a regex + */ + private globToRegex(pattern: string): RegExp { + // Escape special regex chars except * and ? + let regexStr = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '{{GLOBSTAR}}') + .replace(/\*/g, '[^/]*') + .replace(/\?/g, '[^/]') + .replace(/\{\{GLOBSTAR\}\}/g, '.*'); + + return new RegExp(`^${regexStr}$`, 'i'); + } + + /** + * List files and folders in a directory + */ + private async vaultList(folder: string): Promise { + const items: FileInfo[] = []; + + // Get folder (root if empty string) + let targetFolder: TFolder; + if (folder === '' || folder === '/') { + targetFolder = this.app.vault.getRoot(); + } else { + const abstractFile = + this.app.vault.getAbstractFileByPath(folder); + if (!abstractFile) { + throw new Error(`Folder not found: ${folder}`); + } + if (!(abstractFile instanceof TFolder)) { + throw new Error(`Path is not a folder: ${folder}`); + } + targetFolder = abstractFile; + } + + // List children + for (const child of targetFolder.children) { + items.push({ + name: child.name, + path: child.path, + type: child instanceof TFolder ? 'folder' : 'file', + }); + } + + // Sort: folders first, then alphabetically + items.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'folder' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return items; + } + + /** + * Rename or move a file + */ + private async vaultRename( + oldPath: string, + newPath: string + ): Promise<{ success: boolean }> { + const file = this.app.vault.getAbstractFileByPath(oldPath); + + if (!file) { + throw new Error(`File not found: ${oldPath}`); + } + + // Ensure parent folder of new path exists + const newFolderPath = newPath.substring(0, newPath.lastIndexOf('/')); + if (newFolderPath) { + await this.ensureFolderExists(newFolderPath); + } + + // Rename using Obsidian's fileManager (handles link updates) + await this.app.fileManager.renameFile(file, newPath); + + new Notice(`Renamed: ${oldPath} → ${newPath}`); + + return { success: true }; + } + + /** + * Delete a file (move to trash) + */ + private async vaultDelete(path: string): Promise<{ success: boolean }> { + const file = this.app.vault.getAbstractFileByPath(path); + + if (!file) { + throw new Error(`File not found: ${path}`); + } + + // Move to system trash + await this.app.vault.trash(file, true); + + new Notice(`Deleted: ${path}`); + + return { success: true }; + } + + /** + * Ensure a folder path exists, creating parent folders as needed + */ + private async ensureFolderExists(path: string): Promise { + const parts = path.split('/').filter((p) => p.length > 0); + let currentPath = ''; + + for (const part of parts) { + currentPath = currentPath ? `${currentPath}/${part}` : part; + const existing = + this.app.vault.getAbstractFileByPath(currentPath); + + if (!existing) { + await this.app.vault.createFolder(currentPath); + } else if (!(existing instanceof TFolder)) { + throw new Error( + `Path exists but is not a folder: ${currentPath}` + ); + } + } + } +} diff --git a/src/core/backend/protocol.ts b/src/core/backend/protocol.ts new file mode 100644 index 00000000..4c2881c3 --- /dev/null +++ b/src/core/backend/protocol.ts @@ -0,0 +1,155 @@ +/** + * WebSocket Protocol Types + * + * Defines the bidirectional message types for communication between + * the backend server and the Obsidian plugin. + */ + +// ============================================================================ +// Shared Types +// ============================================================================ + +export interface SearchResult { + path: string; + snippet: string; + score?: number; +} + +export interface FileInfo { + name: string; + path: string; + type: 'file' | 'folder'; +} + +export interface GrepResult { + path: string; + line: number; + content: string; + context?: string; +} + +export interface AgentContext { + currentFile?: string; + selection?: string; +} + +// ============================================================================ +// Client → Server Messages +// ============================================================================ + +/** Send a prompt to the agent */ +export interface PromptMessage { + type: 'prompt'; + id: string; + prompt: string; + context?: AgentContext; +} + +/** Response to an RPC request from server */ +export interface RpcResponseMessage { + type: 'rpc_response'; + id: string; + result?: unknown; + error?: { + code: string; + message: string; + }; +} + +/** Cancel ongoing agent operation */ +export interface CancelMessage { + type: 'cancel'; + id: string; +} + +/** Keepalive ping */ +export interface PingMessage { + type: 'ping'; +} + +export type ClientMessage = + | PromptMessage + | RpcResponseMessage + | CancelMessage + | PingMessage; + +// ============================================================================ +// Server → Client Messages +// ============================================================================ + +/** Streaming text from agent */ +export interface TextDeltaMessage { + type: 'text_delta'; + requestId: string; + text: string; +} + +/** Agent is using a tool */ +export interface ToolStartMessage { + type: 'tool_start'; + requestId: string; + toolName: string; + toolInput: Record; +} + +/** Tool finished */ +export interface ToolEndMessage { + type: 'tool_end'; + requestId: string; + toolName: string; + result: string; +} + +/** Agent thinking (for transparency) */ +export interface ThinkingMessage { + type: 'thinking'; + requestId: string; + text: string; +} + +/** Agent finished */ +export interface CompleteMessage { + type: 'complete'; + requestId: string; + result: string; +} + +/** Error occurred */ +export interface ErrorMessage { + type: 'error'; + requestId?: string; + code: string; + message: string; +} + +/** RPC request - server asking plugin to perform vault operation */ +export interface RpcRequestMessage { + type: 'rpc_request'; + id: string; + method: + | 'vault_read' + | 'vault_write' + | 'vault_edit' + | 'vault_search' + | 'vault_grep' + | 'vault_glob' + | 'vault_list' + | 'vault_rename' + | 'vault_delete'; + params: Record; +} + +/** Keepalive response */ +export interface PongMessage { + type: 'pong'; +} + +export type ServerMessage = + | TextDeltaMessage + | ToolStartMessage + | ToolEndMessage + | ThinkingMessage + | CompleteMessage + | ErrorMessage + | RpcRequestMessage + | PongMessage; diff --git a/src/core/backend/tool-result-formatter.ts b/src/core/backend/tool-result-formatter.ts new file mode 100644 index 00000000..f55e7bb8 --- /dev/null +++ b/src/core/backend/tool-result-formatter.ts @@ -0,0 +1,146 @@ +/** + * Formats tool results as clickable file references for the UI + * Uses Obsidian wikilink format [[filename]] for inline clickable links + */ + +export function formatToolResult( + toolName: string, + result: string, + toolArguments: string +): string { + let formattedResult = ''; + + if (toolName === 'vault_search') { + // vault_search format: "- path/to/file.md: ...snippet..." + // Only extract actual file paths (must end with .md or other extension before the colon) + const lines = result.split('\n'); + const fileRefs: string[] = []; + + for (const line of lines) { + // Match lines like "- path/to/file.md: snippet text" + // Path can contain spaces, must end with an extension before the colon + // Format is: "- filepath.ext: content" + const match = line.match(/^-\s*(.+\.\w+):\s/); + if (match) { + const filepath = match[1].trim(); + const displayName = filepath.split('/').pop() || filepath; + fileRefs.push(`[[${filepath}|${displayName}]]`); + } + } + + if (fileRefs.length > 0) { + formattedResult = '\n**Found files:**\n' + fileRefs.map(ref => `- ${ref}`).join('\n'); + } + } else if (toolName === 'vault_list') { + // vault_list format: "- 📁 folder-name" or "- 📄 file-name.md" + const lines = result.split('\n'); + const fileRefs: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('- ')) continue; + + // Check for folder (📁) + if (trimmed.includes('📁')) { + const name = trimmed.replace(/^-\s*📁\s*/, '').trim(); + fileRefs.push(`📁 ${name}`); + } + // Check for file (📄) + else if (trimmed.includes('📄')) { + const name = trimmed.replace(/^-\s*📄\s*/, '').trim(); + fileRefs.push(`[[${name}]]`); + } + } + + if (fileRefs.length > 0) { + formattedResult = '\n**Contents:**\n' + fileRefs.map(ref => `- ${ref}`).join('\n'); + } + } else if (toolName === 'vault_read') { + // For vault_read, show the filename as a link + try { + const args = JSON.parse(toolArguments); + if (args.path) { + const displayName = args.path.split('/').pop() || args.path; + formattedResult = `\n📖 Read: [[${args.path}|${displayName}]]`; + } + } catch (e) { + // Silently ignore parse errors + } + } else if (toolName === 'vault_write') { + // For vault_write, show the written file + try { + const args = JSON.parse(toolArguments); + if (args.path) { + const displayName = args.path.split('/').pop() || args.path; + formattedResult = `\n✍️ Wrote: [[${args.path}|${displayName}]]`; + } + } catch (e) { + // Silently ignore parse errors + } + } else if (toolName === 'vault_edit') { + // For vault_edit, show the edited file + try { + const args = JSON.parse(toolArguments); + if (args.path) { + const displayName = args.path.split('/').pop() || args.path; + formattedResult = `\n✏️ Edited: [[${args.path}|${displayName}]]`; + } + } catch (e) { + // Silently ignore parse errors + } + } else if (toolName === 'vault_grep') { + // vault_grep format: "path/to/file.md:123: matching line content" + const lines = result.split('\n'); + const fileRefs: string[] = []; + const seenFiles = new Set(); + + for (const line of lines) { + // Match lines like "path/to/file.md:42: content" + const match = line.match(/^(.+\.\w+):(\d+):/); + if (match) { + const filepath = match[1].trim(); + const lineNum = match[2]; + // Only show each file once + if (!seenFiles.has(filepath)) { + seenFiles.add(filepath); + const displayName = filepath.split('/').pop() || filepath; + fileRefs.push(`[[${filepath}|${displayName}]] (line ${lineNum})`); + } + } + } + + if (fileRefs.length > 0) { + formattedResult = '\n**Matches in:**\n' + fileRefs.map(ref => `- ${ref}`).join('\n'); + } + } else if (toolName === 'vault_glob') { + // vault_glob format: "- path/to/file.md" + const lines = result.split('\n'); + const fileRefs: string[] = []; + + for (const line of lines) { + const match = line.match(/^-\s*(.+\.\w+)$/); + if (match) { + const filepath = match[1].trim(); + const displayName = filepath.split('/').pop() || filepath; + fileRefs.push(`[[${filepath}|${displayName}]]`); + } + } + + if (fileRefs.length > 0) { + formattedResult = '\n**Matched files:**\n' + fileRefs.map(ref => `- ${ref}`).join('\n'); + } + } else if (toolName === 'vault_rename') { + // For vault_rename, show both old and new paths + try { + const args = JSON.parse(toolArguments); + if (args.old_path && args.new_path) { + const newDisplayName = args.new_path.split('/').pop() || args.new_path; + formattedResult = `\n📝 Renamed → [[${args.new_path}|${newDisplayName}]]`; + } + } catch (e) { + // Silently ignore parse errors + } + } + + return formattedResult; +} From f91493eff54673786cb266a8dfb88eabbed7a014 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:24:51 -0500 Subject: [PATCH 07/40] Add CLAUDE.md and custom skills support - Loads CLAUDE.md from vault root for project-specific instructions - Loads .claude/instructions.md as alternative location - Loads custom skills from .claude/skills/*.md - Skills can be invoked by name (e.g., "/weekly-review") - Updates system prompt with new tool capabilities Co-Authored-By: Claude Opus 4.5 --- backend/src/agent.ts | 118 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 8 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 6fc104aa..1d2fcc81 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -18,31 +18,130 @@ import type { AgentEvent, } from './protocol.js'; -const SYSTEM_PROMPT = `You are an Obsidian note-editing assistant. You help users create, edit, search, and organize their notes in their Obsidian vault. +const BASE_SYSTEM_PROMPT = `You are an Obsidian note-editing assistant. You help users create, edit, search, and organize their notes in their Obsidian vault. ## Capabilities - Read notes from the vault - Write/create notes -- Search across the vault +- Edit notes with precise string replacement (vault_edit) +- Search across the vault (vault_search for text, vault_grep for regex patterns) +- Find files by pattern (vault_glob) - List files and folders +- Rename/move notes (vault_rename) - Delete notes (ask for confirmation first) ## Guidelines 1. When editing existing notes, ALWAYS read them first to understand current content -2. Preserve existing formatting and structure unless asked to change it -3. Use proper Obsidian markdown: +2. Use vault_edit for small precise changes - it's more efficient than rewriting the whole file +3. Preserve existing formatting and structure unless asked to change it +4. Use proper Obsidian markdown: - [[wikilinks]] for internal links - #tags for categorization - YAML frontmatter for metadata -4. When creating new notes, suggest appropriate folder locations -5. For destructive operations (delete, overwrite), confirm with the user first -6. If a search returns no results, suggest alternative search terms +5. When creating new notes, suggest appropriate folder locations +6. For destructive operations (delete, overwrite), confirm with the user first +7. If a search returns no results, suggest alternative search terms or use vault_grep with regex ## Response Style - Be concise but helpful - Explain what changes you're making - If uncertain, ask for clarification`; +interface Skill { + name: string; + description: string; + content: string; +} + +/** + * Load custom skills from .claude/skills/ directory + */ +async function loadSkills(bridge: VaultBridge): Promise { + const skills: Skill[] = []; + + try { + // Try to list files in .claude/skills/ + const skillFiles = await bridge.glob('.claude/skills/*.md'); + + for (const skillPath of skillFiles) { + try { + const content = await bridge.read(skillPath); + // Extract skill name from filename (e.g., "weekly-review.md" -> "weekly-review") + const filename = skillPath.split('/').pop() || skillPath; + const name = filename.replace(/\.md$/, ''); + + // Try to extract description from first line or frontmatter + let description = `Custom skill: ${name}`; + const lines = content.split('\n'); + if (lines[0]?.startsWith('# ')) { + description = lines[0].replace('# ', '').trim(); + } else if (lines[0]?.startsWith('---')) { + // Try to parse YAML frontmatter for description + const frontmatterEnd = content.indexOf('---', 4); + if (frontmatterEnd > 0) { + const frontmatter = content.substring(4, frontmatterEnd); + const descMatch = frontmatter.match(/description:\s*(.+)/); + if (descMatch) { + description = descMatch[1].trim(); + } + } + } + + skills.push({ name, description, content }); + logger.info(`Loaded skill: ${name}`); + } catch (e) { + logger.warn(`Failed to load skill from ${skillPath}:`, e); + } + } + } catch (e) { + // .claude/skills/ doesn't exist, that's fine + logger.debug('No .claude/skills/ directory found'); + } + + return skills; +} + +/** + * Build the full system prompt, including CLAUDE.md content and custom skills + */ +async function buildSystemPrompt(bridge: VaultBridge): Promise { + let systemPrompt = BASE_SYSTEM_PROMPT; + + // Try to read CLAUDE.md from vault root for project-specific context + try { + const claudeMd = await bridge.read('CLAUDE.md'); + if (claudeMd && claudeMd.trim()) { + systemPrompt += `\n\n## Vault-Specific Instructions (from CLAUDE.md)\n\n${claudeMd}`; + logger.info('Loaded CLAUDE.md from vault root'); + } + } catch (e) { + // CLAUDE.md doesn't exist, that's fine + logger.debug('No CLAUDE.md found in vault root'); + } + + // Also check for .claude/instructions.md as an alternative location + try { + const instructions = await bridge.read('.claude/instructions.md'); + if (instructions && instructions.trim()) { + systemPrompt += `\n\n## Additional Instructions (from .claude/instructions.md)\n\n${instructions}`; + logger.info('Loaded .claude/instructions.md'); + } + } catch (e) { + // .claude/instructions.md doesn't exist, that's fine + } + + // Load custom skills + const skills = await loadSkills(bridge); + if (skills.length > 0) { + systemPrompt += `\n\n## Custom Skills\n\nThe user has defined the following custom skills. When they reference a skill by name (e.g., "run the weekly-review skill" or "/weekly-review"), follow the instructions in that skill:\n\n`; + for (const skill of skills) { + systemPrompt += `### Skill: ${skill.name}\n${skill.description}\n\n\`\`\`\n${skill.content}\n\`\`\`\n\n`; + } + } + + return systemPrompt; +} + const MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514'; const MAX_TOKENS = 4096; const MAX_ITERATIONS = 10; // Prevent infinite tool loops @@ -65,6 +164,9 @@ export async function* runAgent( const tools = getVaultToolDefinitions(); const messages: ConversationMessage[] = []; + // Build system prompt with CLAUDE.md context + const systemPrompt = await buildSystemPrompt(bridge); + // Build context-aware prompt let fullPrompt = prompt; if (context?.currentFile) { @@ -93,7 +195,7 @@ export async function* runAgent( const stream = await client.messages.stream({ model: MODEL, max_tokens: MAX_TOKENS, - system: SYSTEM_PROMPT, + system: systemPrompt, tools: tools as Anthropic.Tool[], messages: messages as Anthropic.MessageParam[], }); From e9f7762809e73a88450dcbd5caff3814cf30c3a3 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:01:26 -0500 Subject: [PATCH 08/40] Default to Claude Opus 4.5 model Co-Authored-By: Claude Opus 4.5 --- backend/src/agent.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 1d2fcc81..7f51cf30 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -142,7 +142,7 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { return systemPrompt; } -const MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514'; +const MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-5-20250514'; const MAX_TOKENS = 4096; const MAX_ITERATIONS = 10; // Prevent infinite tool loops @@ -158,14 +158,20 @@ export async function* runAgent( prompt: string, bridge: VaultBridge, context?: AgentContext, - signal?: AbortSignal + signal?: AbortSignal, + customSystemPrompt?: string ): AsyncGenerator { const client = new Anthropic(); const tools = getVaultToolDefinitions(); const messages: ConversationMessage[] = []; // Build system prompt with CLAUDE.md context - const systemPrompt = await buildSystemPrompt(bridge); + let systemPrompt = await buildSystemPrompt(bridge); + + // Prepend custom system prompt if provided (from user settings) + if (customSystemPrompt && customSystemPrompt.trim()) { + systemPrompt = `${customSystemPrompt.trim()}\n\n${systemPrompt}`; + } // Build context-aware prompt let fullPrompt = prompt; From 4f7ad1d2ada83e41cc620ba1ff21a7e685e06c53 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:06:11 -0500 Subject: [PATCH 09/40] Add dynamic model selection from frontend settings - Add model field to PromptMessage protocol - Pass selected model from BackendProvider to WebSocket - Backend agent now uses model from request instead of hardcoded default - Default still falls back to claude-opus-4-5-20250514 if not specified Co-Authored-By: Claude Opus 4.5 --- backend/src/agent.ts | 9 +- backend/src/mock-agent.ts | 4 +- backend/src/protocol.ts | 4 + backend/src/server.ts | 4 +- src/core/backend/BackendProvider.ts | 352 ++++++++++++++++++++++++ src/core/backend/WebSocketClient.ts | 399 ++++++++++++++++++++++++++++ src/core/backend/protocol.ts | 4 + 7 files changed, 771 insertions(+), 5 deletions(-) create mode 100644 src/core/backend/BackendProvider.ts create mode 100644 src/core/backend/WebSocketClient.ts diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 7f51cf30..91c3da18 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -142,7 +142,7 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { return systemPrompt; } -const MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-5-20250514'; +const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-5-20250514'; const MAX_TOKENS = 4096; const MAX_ITERATIONS = 10; // Prevent infinite tool loops @@ -159,8 +159,11 @@ export async function* runAgent( bridge: VaultBridge, context?: AgentContext, signal?: AbortSignal, - customSystemPrompt?: string + customSystemPrompt?: string, + model?: string ): AsyncGenerator { + const selectedModel = model || DEFAULT_MODEL; + logger.info(`Using model: ${selectedModel}`); const client = new Anthropic(); const tools = getVaultToolDefinitions(); const messages: ConversationMessage[] = []; @@ -199,7 +202,7 @@ export async function* runAgent( try { // Create streaming request const stream = await client.messages.stream({ - model: MODEL, + model: selectedModel, max_tokens: MAX_TOKENS, system: systemPrompt, tools: tools as Anthropic.Tool[], diff --git a/backend/src/mock-agent.ts b/backend/src/mock-agent.ts index 60e1e469..206c9bea 100644 --- a/backend/src/mock-agent.ts +++ b/backend/src/mock-agent.ts @@ -136,7 +136,9 @@ export async function* runMockAgent( prompt: string, bridge: VaultBridge, context?: AgentContext, - signal?: AbortSignal + signal?: AbortSignal, + _customSystemPrompt?: string, + _model?: string ): AsyncGenerator { logger.info('[MOCK] Running mock agent'); diff --git a/backend/src/protocol.ts b/backend/src/protocol.ts index 1f6d5604..6961555d 100644 --- a/backend/src/protocol.ts +++ b/backend/src/protocol.ts @@ -36,6 +36,10 @@ export interface PromptMessage { id: string; prompt: string; context?: AgentContext; + /** Custom system prompt to prepend to the agent's base system prompt */ + systemPrompt?: string; + /** Model to use for this request (e.g., 'claude-opus-4-5-20250514') */ + model?: string; } /** Response to an RPC request from server */ diff --git a/backend/src/server.ts b/backend/src/server.ts index f6159724..9e2711fb 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -110,7 +110,9 @@ class ConnectionHandler { msg.prompt, vaultBridge, msg.context, - abortController.signal + abortController.signal, + msg.systemPrompt, + msg.model )) { if (abortController.signal.aborted) { logger.info(`Request ${msg.id} was cancelled`); diff --git a/src/core/backend/BackendProvider.ts b/src/core/backend/BackendProvider.ts new file mode 100644 index 00000000..d6faf702 --- /dev/null +++ b/src/core/backend/BackendProvider.ts @@ -0,0 +1,352 @@ +/** + * Backend Provider + * + * LLM provider implementation that uses the backend WebSocket service + * instead of making direct API calls. + */ + +import { BaseLLMProvider } from '../llm/base'; +import type { ChatModel } from '../../types/chat-model.types'; +import type { + LLMOptions, + LLMRequestNonStreaming, + LLMRequestStreaming, +} from '../../types/llm/request'; +import type { + LLMResponseNonStreaming, + LLMResponseStreaming, + ToolCallDelta, +} from '../../types/llm/response'; +import type { BackendProviderConfig } from '../../types/provider.types'; +import { formatToolResult } from './tool-result-formatter'; +import type { WebSocketClient } from './WebSocketClient'; + +export class BackendProvider extends BaseLLMProvider { + constructor( + provider: BackendProviderConfig, + private wsClient: WebSocketClient + ) { + super(provider); + } + + /** + * Stream a response from the backend + */ + async streamResponse( + model: ChatModel, + request: LLMRequestStreaming, + options?: LLMOptions + ): Promise> { + if (!this.wsClient.isConnected) { + throw new Error('Backend not connected'); + } + + // Convert request messages to a prompt string + // The backend will handle the full conversation context + const prompt = this.convertRequestToPrompt(request); + + // Extract system prompt from messages if present + const systemMessage = request.messages.find((m) => m.role === 'system'); + const systemPrompt = + systemMessage && typeof systemMessage.content === 'string' + ? systemMessage.content + : undefined; + + // Create async generator to yield chunks + const generator = this.createStreamGenerator( + prompt, + undefined, + options, + systemPrompt, + model.model // Pass the model ID from settings + ); + + return generator; + } + + /** + * Generate a non-streaming response (used for apply view) + */ + async generateResponse( + model: ChatModel, + request: LLMRequestNonStreaming, + options?: LLMOptions + ): Promise { + // For non-streaming, we collect all chunks and return final result + const stream = await this.streamResponse( + model, + { ...request, stream: true }, + options + ); + + let fullContent = ''; + let lastResponse: LLMResponseStreaming | null = null; + + for await (const chunk of stream) { + lastResponse = chunk; + const delta = chunk.choices[0]?.delta; + if (delta?.content) { + fullContent += delta.content; + } + } + + if (!lastResponse) { + throw new Error('No response from backend'); + } + + // Convert final streaming response to non-streaming format + return { + id: lastResponse.id, + object: 'chat.completion', + created: Date.now(), + model: model.model, + choices: [ + { + finish_reason: 'stop', + message: { + role: 'assistant', + content: fullContent, + }, + }, + ], + usage: lastResponse.usage, + }; + } + + /** + * Get embedding for text (not yet supported by backend) + */ + async getEmbedding(model: string, text: string): Promise { + throw new Error( + 'Embeddings not yet supported by backend. Use local embedding provider.' + ); + } + + /** + * Create an async generator that yields streaming chunks + */ + private async *createStreamGenerator( + prompt: string, + context?: { currentFile?: string; selection?: string }, + options?: LLMOptions, + systemPrompt?: string, + model?: string + ): AsyncGenerator { + // State for accumulating responses + const toolCalls: Map< + number, + { + id: string; + name: string; + arguments: string; + result?: string; + } + > = new Map(); + let isComplete = false; + let errorOccurred = false; + + // Create a queue for messages + const messageQueue: LLMResponseStreaming[] = []; + let resolveNext: ((value: LLMResponseStreaming) => void) | null = + null; + + // Helper to enqueue chunks + const enqueueChunk = (chunk: LLMResponseStreaming) => { + if (resolveNext) { + resolveNext(chunk); + resolveNext = null; + } else { + messageQueue.push(chunk); + } + }; + + // Set up event handlers and send prompt + const requestId = await this.wsClient.sendPrompt( + prompt, + { + onTextDelta: (text: string) => { + const chunk: LLMResponseStreaming = { + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [ + { + delta: { content: text }, + finish_reason: null, + }, + ], + }; + enqueueChunk(chunk); + }, + + onToolStart: (name: string, input: Record) => { + // Create a tool call entry with backend__ prefix for UI display + const index = toolCalls.size; + const toolId = `backend-${requestId}-${index}`; + + toolCalls.set(index, { + id: toolId, + name: `backend__${name}`, // Add prefix so UI knows it's from backend + arguments: JSON.stringify(input), + }); + + // Send tool call delta to show in UI + const toolDelta: ToolCallDelta = { + index, + id: toolId, + type: 'function', + function: { + name: `backend__${name}`, + arguments: JSON.stringify(input), + }, + }; + + enqueueChunk({ + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [ + { + delta: { tool_calls: [toolDelta] }, + finish_reason: null, + }, + ], + }); + }, + + onToolEnd: (name: string, result: string) => { + // Store result and send it to UI as clickable file references + for (const tool of Array.from(toolCalls.values())) { + if (tool.name === `backend__${name}` && !tool.result) { + tool.result = result; + + // Format tool results with clickable file references + const formattedResult = formatToolResult(name, result, tool.arguments); + + // Only send formatted result if we have file references + if (formattedResult) { + enqueueChunk({ + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [ + { + delta: { + content: `\n${formattedResult}\n`, + }, + finish_reason: null, + }, + ], + }); + } + break; + } + } + }, + + onThinking: (text: string) => { + const chunk: LLMResponseStreaming = { + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [ + { + delta: { reasoning: text }, + finish_reason: null, + }, + ], + }; + enqueueChunk(chunk); + }, + + onComplete: (result: string) => { + const chunk: LLMResponseStreaming = { + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [ + { + delta: {}, + finish_reason: + toolCalls.size > 0 ? 'tool_calls' : 'stop', + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }; + isComplete = true; + enqueueChunk(chunk); + }, + + onError: (code: string, message: string) => { + console.error( + `[BackendProvider] Error: ${code} - ${message}` + ); + errorOccurred = true; + isComplete = true; + + const chunk: LLMResponseStreaming = { + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [ + { + delta: {}, + finish_reason: 'stop', + error: { code: 500, message }, + }, + ], + }; + enqueueChunk(chunk); + }, + }, + context, + systemPrompt, + model + ); + + // Yield chunks as they arrive + while (!isComplete || messageQueue.length > 0) { + if (messageQueue.length > 0) { + yield messageQueue.shift()!; + } else { + // Wait for next chunk + const chunk = await new Promise( + (resolve) => { + resolveNext = resolve; + } + ); + yield chunk; + } + + // Check for abort + if (options?.signal?.aborted) { + this.wsClient.cancelRequest(requestId); + break; + } + } + + if (errorOccurred) { + throw new Error('Backend error occurred'); + } + } + + /** + * Convert request messages to a simple prompt string + * The backend's agent will handle the full conversation context + */ + private convertRequestToPrompt( + request: LLMRequestStreaming | LLMRequestNonStreaming + ): string { + // For now, we'll send the entire message history as a JSON string + // The backend can parse this and use it with the agent + return JSON.stringify({ + messages: request.messages, + tools: request.tools, + tool_choice: request.tool_choice, + }); + } +} diff --git a/src/core/backend/WebSocketClient.ts b/src/core/backend/WebSocketClient.ts new file mode 100644 index 00000000..5d92a5e0 --- /dev/null +++ b/src/core/backend/WebSocketClient.ts @@ -0,0 +1,399 @@ +/** + * WebSocket Client for Backend Communication + * + * Manages WebSocket connection to the backend server, handles message + * routing, and provides event-based API for streaming responses and RPC. + * + * Mobile-compatible: Uses browser's native WebSocket API. + */ + +import type { + ClientMessage, + ServerMessage, + PromptMessage, + AgentContext, + RpcRequestMessage, +} from './protocol'; + +/** + * Generate a random UUID (browser-compatible) + */ +function randomUUID(): string { + // Use browser's crypto.randomUUID if available (modern browsers) + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback for older browsers + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + } + ); +} + +export interface WebSocketClientConfig { + url: string; + token: string; +} + +export interface StreamingHandlers { + onTextDelta?: (text: string) => void; + onToolStart?: (name: string, input: Record) => void; + onToolEnd?: (name: string, result: string) => void; + onThinking?: (text: string) => void; + onComplete?: (result: string) => void; + onError?: (code: string, message: string) => void; +} + +type EventHandler = (...args: unknown[]) => void; + +export class WebSocketClient { + private ws: WebSocket | null = null; + private config: WebSocketClientConfig | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private activeHandlers = new Map(); + private pingInterval: number | null = null; + private eventHandlers = new Map>(); + private pendingRpcs = new Map< + string, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + private messageQueue: ClientMessage[] = []; + private isConnecting = false; + + /** + * Connect to the backend WebSocket server + */ + async connect(url: string, token: string): Promise { + if (this.isConnecting) { + throw new Error('Connection already in progress'); + } + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return; // Already connected + } + + this.config = { url, token }; + this.isConnecting = true; + + return new Promise((resolve, reject) => { + const wsUrl = `${url}?token=${encodeURIComponent(token)}`; + + try { + this.ws = new WebSocket(wsUrl); + } catch (error) { + this.isConnecting = false; + reject(error); + return; + } + + const onOpen = () => { + console.log('[WebSocketClient] Connected to backend'); + this.reconnectAttempts = 0; + this.isConnecting = false; + this.startPingInterval(); + this.flushMessageQueue(); + this.emit('connect'); + cleanup(); + resolve(); + }; + + const onError = (event: Event) => { + console.error('[WebSocketClient] Connection error:', event); + this.isConnecting = false; + cleanup(); + reject( + new Error( + 'Failed to connect to backend. Check URL and token.' + ) + ); + }; + + const onClose = (event: CloseEvent) => { + console.log( + '[WebSocketClient] Connection closed:', + event.code, + event.reason + ); + this.isConnecting = false; + cleanup(); + if (event.code !== 1000) { + // Not a normal closure + reject(new Error(`Connection closed: ${event.reason}`)); + } + }; + + const cleanup = () => { + this.ws?.removeEventListener('open', onOpen); + this.ws?.removeEventListener('error', onError); + this.ws?.removeEventListener('close', onClose); + }; + + this.ws.addEventListener('open', onOpen); + this.ws.addEventListener('error', onError); + this.ws.addEventListener('close', onClose); + + // Set up permanent message and close handlers + this.ws.addEventListener('message', this.handleMessage.bind(this)); + this.ws.addEventListener( + 'close', + this.handleDisconnect.bind(this) + ); + }); + } + + /** + * Disconnect from the backend + */ + disconnect(): void { + this.stopPingInterval(); + if (this.ws) { + this.ws.close(1000, 'Client disconnecting'); + this.ws = null; + } + this.config = null; + this.activeHandlers.clear(); + this.pendingRpcs.clear(); + this.messageQueue = []; + } + + /** + * Send a prompt to the agent and get streaming responses + */ + async sendPrompt( + prompt: string, + handlers: StreamingHandlers, + context?: AgentContext, + systemPrompt?: string, + model?: string + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('Not connected to backend'); + } + + const id = randomUUID(); + this.activeHandlers.set(id, handlers); + + const message: PromptMessage = { + type: 'prompt', + id, + prompt, + context, + systemPrompt, + model, + }; + + this.send(message); + return id; + } + + /** + * Cancel an ongoing request + */ + cancelRequest(requestId: string): void { + this.send({ type: 'cancel', id: requestId }); + this.activeHandlers.delete(requestId); + } + + /** + * Send an RPC response back to the server + */ + sendRpcResponse( + id: string, + result?: unknown, + error?: { code: string; message: string } + ): void { + this.send({ + type: 'rpc_response', + id, + result, + error, + }); + } + + /** + * Register an event handler + */ + on(event: string, handler: EventHandler): void { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, new Set()); + } + this.eventHandlers.get(event)!.add(handler); + } + + /** + * Unregister an event handler + */ + off(event: string, handler: EventHandler): void { + const handlers = this.eventHandlers.get(event); + if (handlers) { + handlers.delete(handler); + } + } + + /** + * Check if connected + */ + get isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + private handleMessage(event: MessageEvent): void { + let msg: ServerMessage; + try { + msg = JSON.parse(event.data) as ServerMessage; + } catch { + console.error('[WebSocketClient] Invalid JSON from server'); + return; + } + + switch (msg.type) { + case 'text_delta': { + const handler = this.activeHandlers.get(msg.requestId); + handler?.onTextDelta?.(msg.text); + break; + } + case 'tool_start': { + const handler = this.activeHandlers.get(msg.requestId); + handler?.onToolStart?.(msg.toolName, msg.toolInput); + break; + } + case 'tool_end': { + const handler = this.activeHandlers.get(msg.requestId); + handler?.onToolEnd?.(msg.toolName, msg.result); + break; + } + case 'thinking': { + const handler = this.activeHandlers.get(msg.requestId); + handler?.onThinking?.(msg.text); + break; + } + case 'complete': { + const handler = this.activeHandlers.get(msg.requestId); + handler?.onComplete?.(msg.result); + this.activeHandlers.delete(msg.requestId); + break; + } + case 'error': { + if (msg.requestId) { + const handler = this.activeHandlers.get(msg.requestId); + handler?.onError?.(msg.code, msg.message); + this.activeHandlers.delete(msg.requestId); + } else { + this.emit('error', msg.code, msg.message); + } + break; + } + case 'rpc_request': { + this.emit('rpc_request', msg); + break; + } + case 'pong': { + // Keepalive response, no action needed + break; + } + } + } + + private handleDisconnect(event: CloseEvent): void { + console.log('[WebSocketClient] Disconnected:', event.code); + this.stopPingInterval(); + this.emit('disconnect', event.code, event.reason); + + // Reject all pending RPCs + for (const [id, pending] of this.pendingRpcs) { + pending.reject(new Error('Connection closed')); + } + this.pendingRpcs.clear(); + + // Attempt reconnection if not a normal closure + if (event.code !== 1000 && this.config) { + this.attemptReconnect(); + } + } + + private attemptReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error( + '[WebSocketClient] Max reconnection attempts reached' + ); + this.emit('error', 'MAX_RECONNECTS', 'Failed to reconnect'); + return; + } + + this.reconnectAttempts++; + const delay = + this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + console.log( + `[WebSocketClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})` + ); + + setTimeout(async () => { + if (this.config) { + try { + await this.connect(this.config.url, this.config.token); + } catch (error) { + console.error( + '[WebSocketClient] Reconnection failed:', + error + ); + this.attemptReconnect(); + } + } + }, delay); + } + + private send(msg: ClientMessage): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } else { + // Queue message for when connection is restored + this.messageQueue.push(msg); + } + } + + private flushMessageQueue(): void { + while (this.messageQueue.length > 0 && this.isConnected) { + const msg = this.messageQueue.shift(); + if (msg) { + this.ws!.send(JSON.stringify(msg)); + } + } + } + + private startPingInterval(): void { + this.pingInterval = window.setInterval(() => { + if (this.isConnected) { + this.send({ type: 'ping' }); + } + }, 30000); // Ping every 30 seconds + } + + private stopPingInterval(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + } + + private emit(event: string, ...args: unknown[]): void { + const handlers = this.eventHandlers.get(event); + if (handlers) { + for (const handler of handlers) { + try { + handler(...args); + } catch (error) { + console.error( + `[WebSocketClient] Error in ${event} handler:`, + error + ); + } + } + } + } +} diff --git a/src/core/backend/protocol.ts b/src/core/backend/protocol.ts index 4c2881c3..0729e33b 100644 --- a/src/core/backend/protocol.ts +++ b/src/core/backend/protocol.ts @@ -43,6 +43,10 @@ export interface PromptMessage { id: string; prompt: string; context?: AgentContext; + /** Custom system prompt to prepend to the agent's base system prompt */ + systemPrompt?: string; + /** Model to use for this request (e.g., 'claude-opus-4-5-20250514') */ + model?: string; } /** Response to an RPC request from server */ From 7663f2ef76e2c415efdb37574cb11899f42c6c02 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:48:02 -0500 Subject: [PATCH 10/40] Fix model IDs to use correct release dates - claude-opus-4-5-20251101 (was 20250514) - claude-sonnet-4-5-20250929 - claude-haiku-4-5-20251001 - claude-opus-4-1-20250805 Co-Authored-By: Claude Opus 4.5 --- backend/src/agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 91c3da18..0e0a47b9 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -142,7 +142,7 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { return systemPrompt; } -const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-5-20250514'; +const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-5-20251101'; const MAX_TOKENS = 4096; const MAX_ITERATIONS = 10; // Prevent infinite tool loops From ea2d257f15f7894db863fb54253e83acb02723e3 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:07:32 -0500 Subject: [PATCH 11/40] Improve tool result UX with collapsed lists and edit batching - Collapsed file lists: show first 3 files with "+N more" instead of full lists - Edit batching: suppress duplicate edit messages within 5 second window - Clean up temp test scripts and debug files - Add ConflictManager and instance singleton for backend - Update various UI components for better integration Co-Authored-By: Claude Opus 4.5 --- .claude/settings.json | 8 + manifest.json | 4 +- package.json | 2 +- src/ChatView.tsx | 2 +- src/components/chat-view/ToolMessage.tsx | 121 +++++++----- .../plugins/template/TemplatePlugin.tsx | 148 +++++++++++--- src/components/modals/ErrorModal.tsx | 4 +- .../modals/InstallerUpdateRequiredModal.tsx | 6 +- src/components/settings/SettingsTabRoot.tsx | 8 +- .../settings/modals/AddChatModelModal.tsx | 19 +- .../modals/AddEmbeddingModelModal.tsx | 12 +- .../settings/modals/ProviderFormModal.tsx | 185 +++++++++++------- .../settings/sections/ProvidersSection.tsx | 73 ++++--- src/constants.ts | 28 ++- src/core/backend/ConflictManager.ts | 122 ++++++++++++ src/core/backend/instance.ts | 18 ++ src/core/backend/tool-result-formatter.ts | 117 +++++++---- src/core/llm/manager.ts | 5 + src/hooks/useSkills.ts | 176 +++++++++++++++++ src/main.ts | 77 +++++++- src/types/chat-model.types.ts | 4 + src/types/provider.types.ts | 15 ++ styles.css | 44 ++++- 23 files changed, 951 insertions(+), 247 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/core/backend/ConflictManager.ts create mode 100644 src/core/backend/instance.ts create mode 100644 src/hooks/useSkills.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..e1a2e860 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(node:*)", + "Bash" + ] + } +} diff --git a/manifest.json b/manifest.json index 32db649b..982288ff 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { - "id": "smart-composer", - "name": "Smart Composer", + "id": "claudsidian", + "name": "Claudsidian", "version": "1.2.5", "minAppVersion": "0.15.0", "description": "AI chat with note context, smart writing assistance, and one-click edits for your vault.", diff --git a/package.json b/package.json index 3964d8b6..5722af38 100644 --- a/package.json +++ b/package.json @@ -83,4 +83,4 @@ "vscode-diff": "^2.1.1", "zod": "^3.23.8" } -} \ No newline at end of file +} diff --git a/src/ChatView.tsx b/src/ChatView.tsx index 054f4981..62aff2d3 100644 --- a/src/ChatView.tsx +++ b/src/ChatView.tsx @@ -39,7 +39,7 @@ export class ChatView extends ItemView { } getDisplayText() { - return 'Smart composer chat' + return 'Claudsidian Chat' } async onOpen() { diff --git a/src/components/chat-view/ToolMessage.tsx b/src/components/chat-view/ToolMessage.tsx index 1caf8938..708301e3 100644 --- a/src/components/chat-view/ToolMessage.tsx +++ b/src/components/chat-view/ToolMessage.tsx @@ -57,9 +57,20 @@ const ToolMessage = memo(function ToolMessage({ conversationId: string onMessageUpdate: (message: ChatToolMessage) => void }) { + // Filter out backend tool calls - they clutter the UI and their results + // are shown inline as formatted content instead + const visibleToolCalls = message.toolCalls.filter( + (toolCall) => !toolCall.request.name.startsWith('backend__') + ) + + // If all tool calls are backend tools, don't render the container + if (visibleToolCalls.length === 0) { + return null + } + return (
- {message.toolCalls.map((toolCall, index) => ( + {visibleToolCalls.map((toolCall, index) => (
0 && 'smtcmp-toolcall-border-top')} @@ -107,14 +118,27 @@ function ToolCallItem({ response.status === ToolCallResponseStatus.PendingApproval, ) - const { serverName, toolName } = useMemo(() => { + const { serverName, toolName, isBackendTool } = useMemo(() => { + // Check if this is a backend tool (executed on backend, shown for transparency) + if (request.name.startsWith('backend__')) { + return { + serverName: 'backend', + toolName: request.name.replace('backend__', ''), + isBackendTool: true, + } + } + try { - return parseToolName(request.name) + return { + ...parseToolName(request.name), + isBackendTool: false, + } } catch (error) { if (error instanceof InvalidToolNameException) { return { serverName: null, toolName: request.name, + isBackendTool: false, } } throw error @@ -171,53 +195,54 @@ function ToolCallItem({ )}
)} - {(response.status === ToolCallResponseStatus.PendingApproval || - response.status === ToolCallResponseStatus.Running) && ( -
- {response.status === ToolCallResponseStatus.PendingApproval && ( -
- { - handleToolCall() - setIsOpen(false) - }} - menuOptions={[ - { - label: 'Always allow this tool', - onClick: () => { - handleToolCall() - handleAllowAutoExecution() - setIsOpen(false) + {!isBackendTool && + (response.status === ToolCallResponseStatus.PendingApproval || + response.status === ToolCallResponseStatus.Running) && ( +
+ {response.status === ToolCallResponseStatus.PendingApproval && ( +
+ { + handleToolCall() + setIsOpen(false) + }} + menuOptions={[ + { + label: 'Always allow this tool', + onClick: () => { + handleToolCall() + handleAllowAutoExecution() + setIsOpen(false) + }, }, - }, - { - label: 'Allow for this chat', - onClick: () => { - handleToolCall() - handleAllowForConversation() - setIsOpen(false) + { + label: 'Allow for this chat', + onClick: () => { + handleToolCall() + handleAllowForConversation() + setIsOpen(false) + }, }, - }, - ]} - /> - -
- )} - {response.status === ToolCallResponseStatus.Running && ( -
- -
- )} -
- )} + ]} + /> + +
+ )} + {response.status === ToolCallResponseStatus.Running && ( +
+ +
+ )} +
+ )}
) } diff --git a/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx b/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx index fbb8fb5a..2409d6b7 100644 --- a/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx +++ b/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx @@ -1,4 +1,5 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $createTextNode, $getRoot } from 'lexical' import clsx from 'clsx' import { $parseSerializedNode, @@ -10,12 +11,44 @@ import { createPortal } from 'react-dom' import { Template } from '../../../../../database/json/template/types' import { useTemplateManager } from '../../../../../hooks/useJsonManagers' +import { useSkills, Skill, SlashCommand } from '../../../../../hooks/useSkills' import { MenuOption } from '../shared/LexicalMenu' import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch, } from '../typeahead-menu/LexicalTypeaheadMenuPlugin' +type SlashCommandType = 'template' | 'skill' | 'command' + +class SlashCommandOption extends MenuOption { + name: string + description: string + type: SlashCommandType + template?: Template + skill?: Skill + command?: SlashCommand + + constructor( + name: string, + description: string, + type: SlashCommandType, + data: Template | Skill | SlashCommand + ) { + super(name) + this.name = name + this.description = description + this.type = type + if (type === 'template') { + this.template = data as Template + } else if (type === 'skill') { + this.skill = data as Skill + } else { + this.command = data as SlashCommand + } + } +} + +// Keep old class for backwards compatibility class TemplateTypeaheadOption extends MenuOption { name: string template: Template @@ -27,7 +60,7 @@ class TemplateTypeaheadOption extends MenuOption { } } -function TemplateMenuItem({ +function SlashCommandMenuItem({ index, isSelected, onClick, @@ -38,7 +71,7 @@ function TemplateMenuItem({ isSelected: boolean onClick: () => void onMouseEnter: () => void - option: TemplateTypeaheadOption + option: SlashCommandOption }) { return (
  • -
    {option.name}
    +
    + /{option.name} + {option.type === 'skill' && ( + skill + )} + {option.type === 'command' && ( + cmd + )} +
    +
    {option.description}
  • ) @@ -62,22 +104,48 @@ function TemplateMenuItem({ export default function TemplatePlugin() { const [editor] = useLexicalComposerContext() const templateManager = useTemplateManager() + const { skills, commands, searchSkills, searchCommands } = useSkills() const [queryString, setQueryString] = useState(null) - const [searchResults, setSearchResults] = useState([]) + const [templateResults, setTemplateResults] = useState([]) useEffect(() => { if (queryString == null) return - templateManager.searchTemplates(queryString).then(setSearchResults) + templateManager.searchTemplates(queryString).then(setTemplateResults) }, [queryString, templateManager]) - const options = useMemo( - () => - searchResults.map( - (result) => new TemplateTypeaheadOption(result.name, result), - ), - [searchResults], - ) + // Combine commands, skills, and templates into unified options + const options = useMemo(() => { + const result: SlashCommandOption[] = [] + + // Add slash commands first (highest priority) + const matchingCommands = queryString != null ? searchCommands(queryString) : commands + for (const cmd of matchingCommands) { + const desc = cmd.argumentHint + ? `${cmd.description} ${cmd.argumentHint}` + : cmd.description + result.push( + new SlashCommandOption(cmd.name, desc, 'command', cmd) + ) + } + + // Add skills + const matchingSkills = queryString != null ? searchSkills(queryString) : skills + for (const skill of matchingSkills) { + result.push( + new SlashCommandOption(skill.name, skill.description, 'skill', skill) + ) + } + + // Add templates + for (const template of templateResults) { + result.push( + new SlashCommandOption(template.name, 'Template', 'template', template) + ) + } + + return result + }, [templateResults, skills, commands, queryString, searchSkills, searchCommands]) const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { minLength: 0, @@ -85,19 +153,51 @@ export default function TemplatePlugin() { const onSelectOption = useCallback( ( - selectedOption: TemplateTypeaheadOption, + selectedOption: SlashCommandOption, nodeToRemove: TextNode | null, closeMenu: () => void, ) => { editor.update(() => { - const parsedNodes = selectedOption.template.content.nodes.map((node) => - $parseSerializedNode(node), - ) - if (nodeToRemove) { - const parent = nodeToRemove.getParentOrThrow() - parent.splice(nodeToRemove.getIndexWithinParent(), 1, parsedNodes) - const lastNode = parsedNodes[parsedNodes.length - 1] - lastNode.selectEnd() + if (selectedOption.type === 'template' && selectedOption.template) { + // For templates, insert the template content + const parsedNodes = selectedOption.template.content.nodes.map((node) => + $parseSerializedNode(node), + ) + if (nodeToRemove) { + const parent = nodeToRemove.getParentOrThrow() + parent.splice(nodeToRemove.getIndexWithinParent(), 1, parsedNodes) + const lastNode = parsedNodes[parsedNodes.length - 1] + lastNode.selectEnd() + } + } else if (selectedOption.type === 'skill' && selectedOption.skill) { + // For skills, replace with the skill command text + const skillCommand = `Run the "${selectedOption.skill.name}" skill` + if (nodeToRemove) { + const textNode = $createTextNode(skillCommand) + nodeToRemove.replace(textNode) + textNode.selectEnd() + } else { + // Append to root if no node to remove + const root = $getRoot() + const textNode = $createTextNode(skillCommand) + root.append(textNode) + textNode.selectEnd() + } + } else if (selectedOption.type === 'command' && selectedOption.command) { + // For slash commands, insert the command with placeholder for args + const cmdText = selectedOption.command.argumentHint + ? `/${selectedOption.command.name} ` + : `/${selectedOption.command.name}` + if (nodeToRemove) { + const textNode = $createTextNode(cmdText) + nodeToRemove.replace(textNode) + textNode.selectEnd() + } else { + const root = $getRoot() + const textNode = $createTextNode(cmdText) + root.append(textNode) + textNode.selectEnd() + } } closeMenu() }) @@ -106,7 +206,7 @@ export default function TemplatePlugin() { ) return ( - + onQueryChange={setQueryString} onSelectOption={onSelectOption} triggerFn={checkForTriggerMatch} @@ -116,7 +216,7 @@ export default function TemplatePlugin() { anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, ) => - anchorElementRef.current && searchResults.length + anchorElementRef.current && options.length ? createPortal(
      {options.map((option, i: number) => ( - { diff --git a/src/components/modals/ErrorModal.tsx b/src/components/modals/ErrorModal.tsx index 50e222df..4cfb9e05 100644 --- a/src/components/modals/ErrorModal.tsx +++ b/src/components/modals/ErrorModal.tsx @@ -57,7 +57,7 @@ function ErrorModalComponent({ onClick={() => { onClose() window.open( - 'https://github.com/glowingjade/obsidian-smart-composer/issues', + 'https://github.com/ki-cooley/claudsidian/issues', '_blank', ) }} @@ -73,7 +73,7 @@ function ErrorModalComponent({ // @ts-expect-error: setting property exists in Obsidian's App but is not typed app.setting.open() // @ts-expect-error: setting property exists in Obsidian's App but is not typed - app.setting.openTabById('smart-composer') + app.setting.openTabById('claudsidian') }} > Open Settings diff --git a/src/components/modals/InstallerUpdateRequiredModal.tsx b/src/components/modals/InstallerUpdateRequiredModal.tsx index 47b78cbd..c647a418 100644 --- a/src/components/modals/InstallerUpdateRequiredModal.tsx +++ b/src/components/modals/InstallerUpdateRequiredModal.tsx @@ -11,7 +11,7 @@ export class InstallerUpdateRequiredModal extends ReactModal< Component: InstallerUpdateRequiredModalComponent, props: {}, options: { - title: 'Smart Composer Requires Obsidian Update', + title: 'Claudsidian Requires Obsidian Update', }, }) } @@ -21,10 +21,10 @@ function InstallerUpdateRequiredModalComponent() { return (
      - Smart Composer requires a newer version of the Obsidian installer. + Claudsidian requires a newer version of the Obsidian installer. Please note that this is different from Obsidian's in-app updates. You must manually download the latest version of Obsidian to continue - using Smart Composer. + using Claudsidian.
      diff --git a/src/components/settings/SettingsTabRoot.tsx b/src/components/settings/SettingsTabRoot.tsx index 84feadd3..09423b69 100644 --- a/src/components/settings/SettingsTabRoot.tsx +++ b/src/components/settings/SettingsTabRoot.tsx @@ -21,15 +21,15 @@ export function SettingsTabRoot({ app, plugin }: SettingsTabRootProps) { return ( <> - window.open('https://www.buymeacoffee.com/kevin.on', '_blank') + window.open('https://github.com/ki-cooley/claudsidian', '_blank') } cta /> diff --git a/src/components/settings/modals/AddChatModelModal.tsx b/src/components/settings/modals/AddChatModelModal.tsx index 5f11edf3..f803523e 100644 --- a/src/components/settings/modals/AddChatModelModal.tsx +++ b/src/components/settings/modals/AddChatModelModal.tsx @@ -33,9 +33,15 @@ function AddChatModelModalComponent({ plugin, onClose, }: AddChatModelModalComponentProps) { + // Filter out backend provider since it doesn't support chat models directly + const availableProviders = plugin.settings.providers.filter( + (p) => p.type !== 'backend', + ) + const defaultProvider = availableProviders[0] ?? DEFAULT_PROVIDERS[0] + const [formData, setFormData] = useState({ - providerId: DEFAULT_PROVIDERS[0].id, - providerType: DEFAULT_PROVIDERS[0].type, + providerId: defaultProvider.id, + providerType: defaultProvider.type, id: '', model: '', promptLevel: PromptLevel.Default, @@ -90,15 +96,10 @@ function AddChatModelModalComponent({ [ - provider.id, - provider.id, - ]), + availableProviders.map((provider) => [provider.id, provider.id]), )} onChange={(value: string) => { - const provider = plugin.settings.providers.find( - (p) => p.id === value, - ) + const provider = availableProviders.find((p) => p.id === value) if (!provider) { new Notice(`Provider with ID ${value} not found`) return diff --git a/src/components/settings/modals/AddEmbeddingModelModal.tsx b/src/components/settings/modals/AddEmbeddingModelModal.tsx index 9692332e..e57f0c79 100644 --- a/src/components/settings/modals/AddEmbeddingModelModal.tsx +++ b/src/components/settings/modals/AddEmbeddingModelModal.tsx @@ -38,9 +38,15 @@ function AddEmbeddingModelModalComponent({ plugin, onClose, }: AddEmbeddingModelModalComponentProps) { + // Filter to providers that support embedding + const availableProviders = plugin.settings.providers.filter( + (p) => PROVIDER_TYPES_INFO[p.type].supportEmbedding, + ) + const defaultProvider = availableProviders[0] ?? DEFAULT_PROVIDERS[0] + const [formData, setFormData] = useState>({ - providerId: DEFAULT_PROVIDERS[0].id, - providerType: DEFAULT_PROVIDERS[0].type, + providerId: defaultProvider.id, + providerType: defaultProvider.type as Exclude, id: '', model: '', }) @@ -158,7 +164,7 @@ function AddEmbeddingModelModalComponent({ setFormData((prev) => ({ ...prev, providerId: value, - providerType: provider.type, + providerType: provider.type as Exclude, })) }} /> diff --git a/src/components/settings/modals/ProviderFormModal.tsx b/src/components/settings/modals/ProviderFormModal.tsx index eea23c4c..9c3a75b5 100644 --- a/src/components/settings/modals/ProviderFormModal.tsx +++ b/src/components/settings/modals/ProviderFormModal.tsx @@ -156,85 +156,128 @@ function ProviderFormComponent({ )} - - - setFormData((prev) => ({ ...prev, apiKey: value })) - } - /> - - - - - setFormData((prev) => ({ ...prev, baseUrl: value })) - } - /> - - - {providerTypeInfo.additionalSettings.map((setting) => ( - - {setting.type === 'toggle' ? ( - )?.[ - setting.key - ] ?? false + {formData.type === 'backend' ? ( + <> + + + setFormData((prev) => ({ ...prev, backendUrl: value })) } - onChange={(value: boolean) => - setFormData( - (prev) => - ({ - ...prev, - additionalSettings: { - ...(prev.additionalSettings ?? {}), - [setting.key]: value, - }, - }) as LLMProvider, - ) + /> + + + + + setFormData((prev) => ({ ...prev, authToken: value })) } /> - ) : ( + + + ) : ( + <> + )?.[ - setting.key - ] ?? '' + value={formData.apiKey ?? ''} + placeholder="Enter your API Key" + onChange={(value: string) => + setFormData((prev) => ({ ...prev, apiKey: value })) } - placeholder={setting.placeholder} + /> + + + + - setFormData( - (prev) => - ({ - ...prev, - additionalSettings: { - ...(prev.additionalSettings ?? {}), - [setting.key]: value, - }, - }) as LLMProvider, - ) + setFormData((prev) => ({ ...prev, baseUrl: value })) } /> - )} - - ))} + + + )} + + {formData.type !== 'backend' && + providerTypeInfo.additionalSettings.map((setting) => ( + + {setting.type === 'toggle' ? ( + )?.[ + setting.key + ]) ?? + false + } + onChange={(value: boolean) => + setFormData( + (prev) => + ({ + ...prev, + additionalSettings: { + ...('additionalSettings' in prev + ? prev.additionalSettings + : {}), + [setting.key]: value, + }, + }) as LLMProvider, + ) + } + /> + ) : ( + )?.[ + setting.key + ]) || + '') as string + } + placeholder={setting.placeholder} + onChange={(value: string) => + setFormData( + (prev) => + ({ + ...prev, + additionalSettings: { + ...('additionalSettings' in prev + ? prev.additionalSettings + : {}), + [setting.key]: value, + }, + }) as LLMProvider, + ) + } + /> + )} + + ))} - {settings.providers.map((provider) => ( - - {provider.id} - {PROVIDER_TYPES_INFO[provider.type].label} - { - new EditProviderModal(app, plugin, provider).open() - }} - > - {provider.apiKey ? '••••••••' : 'Set API key'} - - -
      - - {!DEFAULT_PROVIDERS.some((v) => v.id === provider.id) && ( + {settings.providers.map((provider) => { + const hasCredentials = + provider.type === 'backend' + ? !!(provider as { authToken?: string }).authToken + : !!provider.apiKey + + return ( + + {provider.id} + {PROVIDER_TYPES_INFO[provider.type].label} + { + new EditProviderModal(app, plugin, provider).open() + }} + > + {hasCredentials + ? '••••••••' + : provider.type === 'backend' + ? 'Set auth token' + : 'Set API key'} + + +
      - )} -
      - - - ))} + {!DEFAULT_PROVIDERS.some( + (v) => v.id === provider.id, + ) && ( + + )} +
      + + + ) + })} diff --git a/src/constants.ts b/src/constants.ts index e46d4802..e5c89d87 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -8,12 +8,12 @@ export const APPLY_VIEW_TYPE = 'smtcmp-apply-view' export const PGLITE_DB_PATH = '.smtcmp_vector_db.tar.gz' // Default model ids -export const DEFAULT_CHAT_MODEL_ID = 'claude-sonnet-4.5' -export const DEFAULT_APPLY_MODEL_ID = 'gpt-4.1-mini' +export const DEFAULT_CHAT_MODEL_ID = 'backend-agent' +export const DEFAULT_APPLY_MODEL_ID = 'backend-agent' // Recommended model ids -export const RECOMMENDED_MODELS_FOR_CHAT = ['claude-sonnet-4.5', 'gpt-4.1'] -export const RECOMMENDED_MODELS_FOR_APPLY = ['gpt-4.1-mini'] +export const RECOMMENDED_MODELS_FOR_CHAT = ['backend-agent', 'claude-sonnet-4.5', 'gpt-4.1'] +export const RECOMMENDED_MODELS_FOR_APPLY = ['backend-agent', 'gpt-4.1-mini'] export const RECOMMENDED_MODELS_FOR_EMBEDDING = [ 'openai/text-embedding-3-small', ] @@ -147,6 +147,14 @@ export const PROVIDER_TYPES_INFO = { }, ], }, + backend: { + label: 'Backend', + defaultProviderId: 'backend', + requireApiKey: false, + requireBaseUrl: false, + supportEmbedding: false, + additionalSettings: [], + }, } as const satisfies Record< LLMProviderType, { @@ -216,6 +224,12 @@ export const DEFAULT_PROVIDERS: readonly LLMProvider[] = [ type: 'morph', id: PROVIDER_TYPES_INFO.morph.defaultProviderId, }, + { + type: 'backend', + id: PROVIDER_TYPES_INFO.backend.defaultProviderId, + backendUrl: '', + authToken: '', + }, ] /** @@ -224,6 +238,12 @@ export const DEFAULT_PROVIDERS: readonly LLMProvider[] = [ * 2. If there's same model id in user's settings, it's data should be overwritten by default model */ export const DEFAULT_CHAT_MODELS: readonly ChatModel[] = [ + { + providerType: 'backend', + providerId: PROVIDER_TYPES_INFO.backend.defaultProviderId, + id: 'backend-agent', + model: 'backend-agent', + }, { providerType: 'anthropic', providerId: PROVIDER_TYPES_INFO.anthropic.defaultProviderId, diff --git a/src/core/backend/ConflictManager.ts b/src/core/backend/ConflictManager.ts new file mode 100644 index 00000000..f17ea195 --- /dev/null +++ b/src/core/backend/ConflictManager.ts @@ -0,0 +1,122 @@ +/** + * Conflict Manager + * + * Tracks file versions to detect conflicts when the backend tries to + * modify files that have changed since they were read. + */ + +import { App, TFile, Notice } from 'obsidian'; + +interface FileVersion { + path: string; + hash: string; + mtime: number; + content: string; // Keep for diff display if needed +} + +export interface ConflictInfo { + path: string; + expectedContent: string; + actualContent: string; + expectedHash: string; + actualHash: string; +} + +export class ConflictManager { + private versions = new Map(); + + constructor(private app: App) { + // Watch for external file modifications + this.app.vault.on('modify', (file) => { + if (file instanceof TFile && this.versions.has(file.path)) { + this.handleExternalModify(file); + } + }); + } + + /** + * Record the version of a file that was read + */ + recordVersion(path: string, content: string, mtime: number): string { + const hash = this.hashContent(content); + this.versions.set(path, { path, hash, mtime, content }); + return hash; + } + + /** + * Clear the tracked version of a file + */ + clearVersion(path: string): void { + this.versions.delete(path); + } + + /** + * Check if a file has conflicts (modified since last read) + */ + async checkConflict(path: string): Promise { + const tracked = this.versions.get(path); + if (!tracked) { + return null; // No tracked version, no conflict possible + } + + const file = this.app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) { + return null; // File was deleted or is not a file + } + + // Check if mtime changed + if (file.stat.mtime === tracked.mtime) { + return null; // No change + } + + // mtime changed, check content hash + const currentContent = await this.app.vault.read(file); + const currentHash = this.hashContent(currentContent); + + if (currentHash === tracked.hash) { + // Content unchanged despite mtime (metadata change only) + return null; + } + + // Conflict detected! + return { + path, + expectedContent: tracked.content, + actualContent: currentContent, + expectedHash: tracked.hash, + actualHash: currentHash, + }; + } + + /** + * Handle external file modifications + */ + private async handleExternalModify(file: TFile): Promise { + const tracked = this.versions.get(file.path); + if (!tracked) return; + + const currentContent = await this.app.vault.read(file); + const currentHash = this.hashContent(currentContent); + + if (currentHash !== tracked.hash) { + console.warn( + `[ConflictManager] File ${file.path} was modified externally` + ); + // Note: We don't show a notice here because the backend will + // check for conflicts before writing. This is just for logging. + } + } + + /** + * Simple hash function for content comparison + */ + private hashContent(content: string): string { + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return hash.toString(36); + } +} diff --git a/src/core/backend/instance.ts b/src/core/backend/instance.ts new file mode 100644 index 00000000..60024aec --- /dev/null +++ b/src/core/backend/instance.ts @@ -0,0 +1,18 @@ +/** + * Backend WebSocket Client Singleton + * + * Provides a shared WebSocketClient instance that can be accessed + * by the main plugin and the LLM provider manager. + */ + +import { WebSocketClient } from './WebSocketClient'; + +/** + * Shared WebSocketClient instance + */ +export const webSocketClient = new WebSocketClient(); + +// Make it globally accessible for debugging +if (typeof window !== 'undefined') { + (window as any).__claudsidianWsClient = webSocketClient; +} diff --git a/src/core/backend/tool-result-formatter.ts b/src/core/backend/tool-result-formatter.ts index f55e7bb8..c749674c 100644 --- a/src/core/backend/tool-result-formatter.ts +++ b/src/core/backend/tool-result-formatter.ts @@ -1,8 +1,42 @@ /** - * Formats tool results as clickable file references for the UI - * Uses Obsidian wikilink format [[filename]] for inline clickable links + * Formats tool results for the UI + * + * Design goals: + * - Collapsed by default with summary counts + * - Clickable file links via wikilinks + * - Minimal clutter for search/read operations + * - Clear display for write/edit operations */ +// Track edits for batching (static to persist across calls) +const recentEdits: Map = new Map(); +const EDIT_BATCH_WINDOW_MS = 5000; // Batch edits within 5 seconds + +/** + * Format a collapsible file list with count summary + */ +function formatCollapsibleList( + title: string, + icon: string, + fileRefs: string[], + maxVisible: number = 3 +): string { + if (fileRefs.length === 0) return ''; + + const count = fileRefs.length; + + // If few files, just show them inline + if (count <= maxVisible) { + return `\n${icon} **${title}:** ${fileRefs.join(', ')}`; + } + + // For many files, show count with expandable list + const visibleRefs = fileRefs.slice(0, maxVisible); + const hiddenCount = count - maxVisible; + + return `\n${icon} **${title}:** ${visibleRefs.join(', ')} *+${hiddenCount} more*`; +} + export function formatToolResult( toolName: string, result: string, @@ -11,15 +45,10 @@ export function formatToolResult( let formattedResult = ''; if (toolName === 'vault_search') { - // vault_search format: "- path/to/file.md: ...snippet..." - // Only extract actual file paths (must end with .md or other extension before the colon) const lines = result.split('\n'); const fileRefs: string[] = []; for (const line of lines) { - // Match lines like "- path/to/file.md: snippet text" - // Path can contain spaces, must end with an extension before the colon - // Format is: "- filepath.ext: content" const match = line.match(/^-\s*(.+\.\w+):\s/); if (match) { const filepath = match[1].trim(); @@ -28,92 +57,103 @@ export function formatToolResult( } } - if (fileRefs.length > 0) { - formattedResult = '\n**Found files:**\n' + fileRefs.map(ref => `- ${ref}`).join('\n'); - } + formattedResult = formatCollapsibleList('Found', '🔍', fileRefs); } else if (toolName === 'vault_list') { - // vault_list format: "- 📁 folder-name" or "- 📄 file-name.md" const lines = result.split('\n'); - const fileRefs: string[] = []; + const folders: string[] = []; + const files: string[] = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('- ')) continue; - // Check for folder (📁) if (trimmed.includes('📁')) { const name = trimmed.replace(/^-\s*📁\s*/, '').trim(); - fileRefs.push(`📁 ${name}`); - } - // Check for file (📄) - else if (trimmed.includes('📄')) { + folders.push(name); + } else if (trimmed.includes('📄')) { const name = trimmed.replace(/^-\s*📄\s*/, '').trim(); - fileRefs.push(`[[${name}]]`); + files.push(`[[${name}]]`); } } - if (fileRefs.length > 0) { - formattedResult = '\n**Contents:**\n' + fileRefs.map(ref => `- ${ref}`).join('\n'); + const total = folders.length + files.length; + if (total > 0) { + const parts: string[] = []; + if (folders.length > 0) parts.push(`${folders.length} folders`); + if (files.length > 0) parts.push(`${files.length} files`); + formattedResult = `\n📂 **Listed:** ${parts.join(', ')}`; } } else if (toolName === 'vault_read') { - // For vault_read, show the filename as a link try { const args = JSON.parse(toolArguments); if (args.path) { const displayName = args.path.split('/').pop() || args.path; - formattedResult = `\n📖 Read: [[${args.path}|${displayName}]]`; + formattedResult = `\n📖 **Read:** [[${args.path}|${displayName}]]`; } } catch (e) { // Silently ignore parse errors } } else if (toolName === 'vault_write') { - // For vault_write, show the written file try { const args = JSON.parse(toolArguments); if (args.path) { const displayName = args.path.split('/').pop() || args.path; - formattedResult = `\n✍️ Wrote: [[${args.path}|${displayName}]]`; + formattedResult = `\n✍️ **Created:** [[${args.path}|${displayName}]]`; } } catch (e) { // Silently ignore parse errors } } else if (toolName === 'vault_edit') { - // For vault_edit, show the edited file try { const args = JSON.parse(toolArguments); if (args.path) { const displayName = args.path.split('/').pop() || args.path; - formattedResult = `\n✏️ Edited: [[${args.path}|${displayName}]]`; + const now = Date.now(); + + // Check if we have a recent edit to this same file + const existingEdit = recentEdits.get(args.path); + if (existingEdit && (now - existingEdit.lastTime) < EDIT_BATCH_WINDOW_MS) { + // Update the count but don't emit another message + existingEdit.count++; + existingEdit.lastTime = now; + // Return empty to suppress duplicate messages + return ''; + } + + // New edit or first in a batch + recentEdits.set(args.path, { count: 1, lastTime: now }); + + // Clean up old entries + for (const [path, edit] of recentEdits) { + if (now - edit.lastTime > EDIT_BATCH_WINDOW_MS * 2) { + recentEdits.delete(path); + } + } + + formattedResult = `\n✏️ **Edited:** [[${args.path}|${displayName}]]`; } } catch (e) { // Silently ignore parse errors } } else if (toolName === 'vault_grep') { - // vault_grep format: "path/to/file.md:123: matching line content" const lines = result.split('\n'); const fileRefs: string[] = []; const seenFiles = new Set(); for (const line of lines) { - // Match lines like "path/to/file.md:42: content" const match = line.match(/^(.+\.\w+):(\d+):/); if (match) { const filepath = match[1].trim(); - const lineNum = match[2]; - // Only show each file once if (!seenFiles.has(filepath)) { seenFiles.add(filepath); const displayName = filepath.split('/').pop() || filepath; - fileRefs.push(`[[${filepath}|${displayName}]] (line ${lineNum})`); + fileRefs.push(`[[${filepath}|${displayName}]]`); } } } - if (fileRefs.length > 0) { - formattedResult = '\n**Matches in:**\n' + fileRefs.map(ref => `- ${ref}`).join('\n'); - } + formattedResult = formatCollapsibleList('Grep matches', '🔎', fileRefs); } else if (toolName === 'vault_glob') { - // vault_glob format: "- path/to/file.md" const lines = result.split('\n'); const fileRefs: string[] = []; @@ -126,16 +166,13 @@ export function formatToolResult( } } - if (fileRefs.length > 0) { - formattedResult = '\n**Matched files:**\n' + fileRefs.map(ref => `- ${ref}`).join('\n'); - } + formattedResult = formatCollapsibleList('Found files', '📁', fileRefs); } else if (toolName === 'vault_rename') { - // For vault_rename, show both old and new paths try { const args = JSON.parse(toolArguments); if (args.old_path && args.new_path) { const newDisplayName = args.new_path.split('/').pop() || args.new_path; - formattedResult = `\n📝 Renamed → [[${args.new_path}|${newDisplayName}]]`; + formattedResult = `\n📝 **Renamed:** → [[${args.new_path}|${newDisplayName}]]`; } } catch (e) { // Silently ignore parse errors diff --git a/src/core/llm/manager.ts b/src/core/llm/manager.ts index b5858952..d43d6ff5 100644 --- a/src/core/llm/manager.ts +++ b/src/core/llm/manager.ts @@ -17,6 +17,8 @@ import { OpenAIAuthenticatedProvider } from './openai' import { OpenAICompatibleProvider } from './openaiCompatibleProvider' import { OpenRouterProvider } from './openRouterProvider' import { PerplexityProvider } from './perplexityProvider' +import { BackendProvider } from '../backend/BackendProvider' +import { webSocketClient } from '../backend/instance' /* * OpenAI, OpenAI-compatible, and Anthropic providers include token usage statistics @@ -76,6 +78,9 @@ export function getProviderClient({ case 'openai-compatible': { return new OpenAICompatibleProvider(provider) } + case 'backend': { + return new BackendProvider(provider, webSocketClient) + } } } diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts new file mode 100644 index 00000000..99821dcb --- /dev/null +++ b/src/hooks/useSkills.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useApp } from '../contexts/app-context'; + +export interface Skill { + name: string; + description: string; + path: string; +} + +export interface SlashCommand { + name: string; + description: string; + path: string; + argumentHint?: string; +} + +/** + * Parse frontmatter from markdown content + */ +function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {}; + let body = content; + + if (content.startsWith('---')) { + const endIndex = content.indexOf('---', 4); + if (endIndex > 0) { + const frontmatterStr = content.substring(4, endIndex); + body = content.substring(endIndex + 3).trim(); + + // Simple YAML parsing for common fields + const lines = frontmatterStr.split('\n'); + for (const line of lines) { + const match = line.match(/^(\w[\w-]*):\s*(.+)$/); + if (match) { + frontmatter[match[1]] = match[2].trim(); + } + } + } + } + + return { frontmatter, body }; +} + +/** + * Hook to load and manage skills from .claude/skills/ directory + * Note: Uses adapter.list() since Obsidian doesn't index dot-folders + */ +export function useSkills() { + const app = useApp(); + const [skills, setSkills] = useState([]); + const [commands, setCommands] = useState([]); + const [loading, setLoading] = useState(true); + + const loadSkillsAndCommands = useCallback(async () => { + setLoading(true); + const loadedSkills: Skill[] = []; + const loadedCommands: SlashCommand[] = []; + + // Load skills from .claude/skills/ + try { + const skillsPath = '.claude/skills'; + const exists = await app.vault.adapter.exists(skillsPath); + + if (exists) { + const listing = await app.vault.adapter.list(skillsPath); + const mdFiles = listing.files.filter((f) => f.endsWith('.md')); + + for (const filePath of mdFiles) { + try { + const content = await app.vault.adapter.read(filePath); + const filename = filePath.split('/').pop() || filePath; + const name = filename.replace(/\.md$/, ''); + + const { frontmatter, body } = parseFrontmatter(content); + let description = frontmatter.description || `Skill: ${name}`; + + // If no description in frontmatter, try first heading + if (!frontmatter.description) { + const firstLine = body.split('\n')[0]; + if (firstLine?.startsWith('# ')) { + description = firstLine.replace('# ', '').trim(); + } + } + + loadedSkills.push({ name, description, path: filePath }); + } catch (e) { + console.warn(`Failed to load skill from ${filePath}:`, e); + } + } + } + } catch (e) { + console.warn('Failed to load skills:', e); + } + + // Load slash commands from .claude/commands/ + try { + const commandsPath = '.claude/commands'; + const exists = await app.vault.adapter.exists(commandsPath); + + if (exists) { + const listing = await app.vault.adapter.list(commandsPath); + const mdFiles = listing.files.filter((f) => f.endsWith('.md')); + + for (const filePath of mdFiles) { + try { + const content = await app.vault.adapter.read(filePath); + const filename = filePath.split('/').pop() || filePath; + const name = filename.replace(/\.md$/, ''); + + const { frontmatter, body } = parseFrontmatter(content); + let description = frontmatter.description || `Command: ${name}`; + const argumentHint = frontmatter['argument-hint']; + + // If no description in frontmatter, use first line of body + if (!frontmatter.description) { + const firstLine = body.split('\n')[0]?.trim(); + if (firstLine && !firstLine.startsWith('#')) { + description = firstLine.substring(0, 60) + (firstLine.length > 60 ? '...' : ''); + } + } + + loadedCommands.push({ name, description, path: filePath, argumentHint }); + } catch (e) { + console.warn(`Failed to load command from ${filePath}:`, e); + } + } + } + } catch (e) { + console.warn('Failed to load commands:', e); + } + + setSkills(loadedSkills); + setCommands(loadedCommands); + setLoading(false); + }, [app]); + + // Load skills and commands on mount + // Note: File watchers won't work for dot-folders, so we just load on mount + // Users can refresh by restarting the plugin or calling refresh() + useEffect(() => { + loadSkillsAndCommands(); + }, [loadSkillsAndCommands]); + + const searchSkills = useCallback( + (query: string): Skill[] => { + const lowerQuery = query.toLowerCase(); + return skills.filter( + (skill) => + skill.name.toLowerCase().includes(lowerQuery) || + skill.description.toLowerCase().includes(lowerQuery) + ); + }, + [skills] + ); + + const searchCommands = useCallback( + (query: string): SlashCommand[] => { + const lowerQuery = query.toLowerCase(); + return commands.filter( + (cmd) => + cmd.name.toLowerCase().includes(lowerQuery) || + cmd.description.toLowerCase().includes(lowerQuery) + ); + }, + [commands] + ); + + return { + skills, + commands, + loading, + searchSkills, + searchCommands, + refresh: loadSkillsAndCommands, + }; +} diff --git a/src/main.ts b/src/main.ts index 22f5e244..7ae4697a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,10 @@ import { import { parseSmartComposerSettings } from './settings/schema/settings' import { SmartComposerSettingTab } from './settings/SettingTab' import { getMentionableBlockData } from './utils/obsidian' +import { webSocketClient } from './core/backend/instance' +import { VaultRpcHandler } from './core/backend/VaultRpcHandler' +import { ConflictManager } from './core/backend/ConflictManager' +import type { RpcRequestMessage } from './core/backend/protocol' export default class SmartComposerPlugin extends Plugin { settings: SmartComposerSettings @@ -25,6 +29,8 @@ export default class SmartComposerPlugin extends Plugin { mcpManager: McpManager | null = null dbManager: DatabaseManager | null = null ragEngine: RAGEngine | null = null + vaultRpcHandler: VaultRpcHandler | null = null + conflictManager: ConflictManager | null = null private dbManagerInitPromise: Promise | null = null private ragEngineInitPromise: Promise | null = null private timeoutIds: ReturnType[] = [] // Use ReturnType instead of number @@ -32,11 +38,42 @@ export default class SmartComposerPlugin extends Plugin { async onload() { await this.loadSettings() + // Initialize backend components + this.conflictManager = new ConflictManager(this.app) + this.vaultRpcHandler = new VaultRpcHandler(this.app) + + // Wire RPC handler to respond to backend requests + webSocketClient.on('rpc_request', async (msg: unknown) => { + const rpcMsg = msg as RpcRequestMessage + console.log( + `[Claudsidian] RPC request received: ${rpcMsg.method}`, + rpcMsg.params, + ) + try { + const result = await this.vaultRpcHandler!.handleRpc( + rpcMsg.method, + rpcMsg.params, + ) + console.log(`[Claudsidian] RPC result for ${rpcMsg.method}:`, result) + webSocketClient.sendRpcResponse(rpcMsg.id, result) + } catch (error) { + console.error('[Claudsidian] RPC handler error:', error) + webSocketClient.sendRpcResponse(rpcMsg.id, undefined, { + code: 'RPC_ERROR', + message: + error instanceof Error ? error.message : 'Unknown error', + }) + } + }) + + // Auto-connect to backend if configured + void this.connectBackend() + this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this)) this.registerView(APPLY_VIEW_TYPE, (leaf) => new ApplyView(leaf)) // This creates an icon in the left ribbon. - this.addRibbonIcon('wand-sparkles', 'Open smart composer', () => + this.addRibbonIcon('wand-sparkles', 'Open Claudsidian chat', () => this.openChatView(), ) @@ -136,6 +173,11 @@ export default class SmartComposerPlugin extends Plugin { this.timeoutIds.forEach((id) => clearTimeout(id)) this.timeoutIds = [] + // Backend cleanup + webSocketClient.disconnect() + this.vaultRpcHandler = null + this.conflictManager = null + // RagEngine cleanup this.ragEngine?.cleanup() this.ragEngine = null @@ -334,8 +376,39 @@ ${validationResult.error.issues.map((v) => v.message).join('\n')}`) if (leaves.length === 0 || !(leaves[0].view instanceof ChatView)) { return } - new Notice('Reloading "smart-composer" due to migration', 1000) + new Notice('Reloading Claudsidian due to migration', 1000) leaves[0].detach() await this.activateChatView() } + + async connectBackend() { + const backendProvider = this.settings.providers.find( + (p) => p.type === 'backend', + ) + if (!backendProvider || backendProvider.type !== 'backend') { + console.log('[SmartComposer] No backend provider configured') + return + } + + if (!backendProvider.backendUrl || !backendProvider.authToken) { + console.warn( + '[SmartComposer] Backend provider missing URL or auth token', + ) + return + } + + try { + console.log('[SmartComposer] Connecting to backend...') + await webSocketClient.connect( + backendProvider.backendUrl, + backendProvider.authToken, + ) + new Notice('Connected to backend') + } catch (error) { + console.error('[SmartComposer] Failed to connect to backend:', error) + new Notice( + 'Failed to connect to backend. Check console for details.', + ) + } + } } diff --git a/src/types/chat-model.types.ts b/src/types/chat-model.types.ts index 25afa7f4..4144b285 100644 --- a/src/types/chat-model.types.ts +++ b/src/types/chat-model.types.ts @@ -95,6 +95,10 @@ export const chatModelSchema = z.discriminatedUnion('providerType', [ providerType: z.literal('openai-compatible'), ...baseChatModelSchema.shape, }), + z.object({ + providerType: z.literal('backend'), + ...baseChatModelSchema.shape, + }), ]) export type ChatModel = z.infer diff --git a/src/types/provider.types.ts b/src/types/provider.types.ts index a9b193f4..45829c31 100644 --- a/src/types/provider.types.ts +++ b/src/types/provider.types.ts @@ -89,7 +89,22 @@ export const llmProviderSchema = z.discriminatedUnion('type', [ }) .optional(), }), + z.object({ + type: z.literal('backend'), + id: z.string().min(1, 'id is required'), + backendUrl: z + .string({ + required_error: 'Backend URL is required', + }) + .min(1, 'Backend URL is required'), + authToken: z + .string({ + required_error: 'Auth token is required', + }) + .min(1, 'Auth token is required'), + }), ]) export type LLMProvider = z.infer export type LLMProviderType = LLMProvider['type'] +export type BackendProviderConfig = Extract diff --git a/styles.css b/styles.css index b0b769c9..8309cd25 100644 --- a/styles.css +++ b/styles.css @@ -929,9 +929,9 @@ button.smtcmp-chat-input-model-select { .smtcmp-template-menu-item { display: flex; - align-items: center; - justify-content: space-between; - gap: var(--size-4-1); + flex-direction: column; + align-items: flex-start; + gap: var(--size-2-1); width: 100%; .smtcmp-template-menu-item-delete { @@ -948,6 +948,44 @@ button.smtcmp-chat-input-model-select { } } +.smtcmp-slash-command-item { + display: flex; + align-items: center; + gap: var(--size-4-2); +} + +.smtcmp-slash-command-name { + font-weight: 500; + color: var(--text-normal); +} + +.smtcmp-slash-command-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: var(--radius-s); + background-color: var(--interactive-accent); + color: var(--text-on-accent); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.smtcmp-badge-skill { + background-color: var(--color-purple); +} + +.smtcmp-badge-command { + background-color: var(--color-green); +} + +.smtcmp-slash-command-desc { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 250px; +} + .smtcmp-assistant-message-actions { display: flex; align-items: center; From 4ed1cbc7766ec282ff5d99a9458d94978c801d92 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 16 Jan 2026 01:04:58 -0500 Subject: [PATCH 12/40] Add web search capability and mobile UX improvements Backend: - Enable built-in Anthropic web_search tool for real-time web access - Update system prompt to mention web search capability Frontend: - Add enterKeyHint="send" for mobile keyboard submit button - Blur input after submit to hide mobile keyboard - Add code block truncation (15 lines) with expand/collapse Co-Authored-By: Claude Opus 4.5 --- backend/src/agent.ts | 14 ++++++- .../chat-view/MarkdownCodeComponent.tsx | 40 +++++++++++++++++-- .../chat-view/chat-input/ChatUserInput.tsx | 6 ++- .../chat-input/LexicalContentEditable.tsx | 2 + src/core/backend/BackendProvider.ts | 4 +- styles.css | 21 ++++++++++ 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 0e0a47b9..3959c05a 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -29,6 +29,7 @@ const BASE_SYSTEM_PROMPT = `You are an Obsidian note-editing assistant. You help - List files and folders - Rename/move notes (vault_rename) - Delete notes (ask for confirmation first) +- Search the web for current information (web_search) - useful for looking up documentation, news, or any external information ## Guidelines 1. When editing existing notes, ALWAYS read them first to understand current content @@ -165,7 +166,15 @@ export async function* runAgent( const selectedModel = model || DEFAULT_MODEL; logger.info(`Using model: ${selectedModel}`); const client = new Anthropic(); - const tools = getVaultToolDefinitions(); + + // Combine vault tools with built-in web search tool + const vaultTools = getVaultToolDefinitions(); + const tools: (Anthropic.Tool | { type: string; name?: string; max_uses?: number })[] = [ + ...vaultTools as Anthropic.Tool[], + // Built-in web search tool + { type: 'web_search_20250305', max_uses: 5 }, + ]; + const messages: ConversationMessage[] = []; // Build system prompt with CLAUDE.md context @@ -201,11 +210,12 @@ export async function* runAgent( try { // Create streaming request + // eslint-disable-next-line @typescript-eslint/no-explicit-any const stream = await client.messages.stream({ model: selectedModel, max_tokens: MAX_TOKENS, system: systemPrompt, - tools: tools as Anthropic.Tool[], + tools: tools as any, // Mix of Anthropic.Tool and built-in tool types messages: messages as Anthropic.MessageParam[], }); diff --git a/src/components/chat-view/MarkdownCodeComponent.tsx b/src/components/chat-view/MarkdownCodeComponent.tsx index 60fbc521..0e1f1974 100644 --- a/src/components/chat-view/MarkdownCodeComponent.tsx +++ b/src/components/chat-view/MarkdownCodeComponent.tsx @@ -1,4 +1,4 @@ -import { Check, CopyIcon, Eye, Loader2, Play } from 'lucide-react' +import { Check, ChevronDown, ChevronUp, CopyIcon, Eye, Loader2, Play } from 'lucide-react' import { PropsWithChildren, useMemo, useState } from 'react' import { useApp } from '../../contexts/app-context' @@ -8,6 +8,9 @@ import { openMarkdownFile } from '../../utils/obsidian' import { ObsidianMarkdown } from './ObsidianMarkdown' import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper' +// Max lines to show before truncating +const MAX_LINES_COLLAPSED = 15 + export default function MarkdownCodeComponent({ onApply, isApplying, @@ -25,11 +28,24 @@ export default function MarkdownCodeComponent({ const [isPreviewMode, setIsPreviewMode] = useState(true) const [copied, setCopied] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) const wrapLines = useMemo(() => { return !language || ['markdown'].includes(language) }, [language]) + const fullContent = String(children) + const lines = fullContent.split('\n') + const totalLines = lines.length + const shouldTruncate = totalLines > MAX_LINES_COLLAPSED + + const displayContent = useMemo(() => { + if (!shouldTruncate || isExpanded) { + return fullContent + } + return lines.slice(0, MAX_LINES_COLLAPSED).join('\n') + }, [fullContent, lines, shouldTruncate, isExpanded]) + const handleCopy = async () => { try { await navigator.clipboard.writeText(String(children)) @@ -112,7 +128,7 @@ export default function MarkdownCodeComponent({
      {isPreviewMode ? (
      - +
      ) : ( - {String(children)} + {displayContent} )} + {shouldTruncate && ( + + )}
      ) } diff --git a/src/components/chat-view/chat-input/ChatUserInput.tsx b/src/components/chat-view/chat-input/ChatUserInput.tsx index 24dc5963..4b07f691 100644 --- a/src/components/chat-view/chat-input/ChatUserInput.tsx +++ b/src/components/chat-view/chat-input/ChatUserInput.tsx @@ -197,7 +197,11 @@ const ChatUserInput = forwardRef( const handleSubmit = (options: { useVaultSearch?: boolean } = {}) => { const content = editorRef.current?.getEditorState()?.toJSON() - content && onSubmit(content, options.useVaultSearch) + if (content) { + // Blur input to hide mobile keyboard after submit + contentEditableRef.current?.blur() + onSubmit(content, options.useVaultSearch) + } } return ( diff --git a/src/components/chat-view/chat-input/LexicalContentEditable.tsx b/src/components/chat-view/chat-input/LexicalContentEditable.tsx index cde805d2..a412c666 100644 --- a/src/components/chat-view/chat-input/LexicalContentEditable.tsx +++ b/src/components/chat-view/chat-input/LexicalContentEditable.tsx @@ -113,6 +113,8 @@ export default function LexicalContentEditable({ }} onFocus={onFocus} ref={contentEditableRef} + // Mobile keyboard hint to show "Send" button instead of Enter + enterKeyHint="send" /> } ErrorBoundary={LexicalErrorBoundary} diff --git a/src/core/backend/BackendProvider.ts b/src/core/backend/BackendProvider.ts index d6faf702..a8218ce4 100644 --- a/src/core/backend/BackendProvider.ts +++ b/src/core/backend/BackendProvider.ts @@ -53,12 +53,14 @@ export class BackendProvider extends BaseLLMProvider { : undefined; // Create async generator to yield chunks + // If model is "backend-agent", let backend use its default model + const modelToUse = model.model === 'backend-agent' ? undefined : model.model; const generator = this.createStreamGenerator( prompt, undefined, options, systemPrompt, - model.model // Pass the model ID from settings + modelToUse ); return generator; diff --git a/styles.css b/styles.css index 8309cd25..8e0825d7 100644 --- a/styles.css +++ b/styles.css @@ -582,6 +582,27 @@ input[type='text'].smtcmp-chat-list-dropdown-item-title-input { padding: var(--size-4-3); } +.smtcmp-code-block-expand-button { + display: flex; + align-items: center; + justify-content: center; + gap: var(--size-4-1); + width: 100%; + padding: var(--size-4-2); + border: none; + border-top: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + color: var(--text-muted); + font-size: var(--font-small); + cursor: pointer; + border-radius: 0 0 var(--radius-s) var(--radius-s); + + &:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); + } +} + #smtcmp-apply-view { flex: 1 1 auto; display: flex; From 0e622ee5d35dd95428f319f1fe6b3b23896784c0 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:06:53 -0500 Subject: [PATCH 13/40] Fix web search tool - add required name field --- backend/Dockerfile | 1 + backend/src/agent.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 334af51a..c08115e6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,6 @@ FROM node:22-slim +# Cache bust: 2026-01-16-v2 WORKDIR /app # Copy package files diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 3959c05a..51f5045f 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -167,12 +167,12 @@ export async function* runAgent( logger.info(`Using model: ${selectedModel}`); const client = new Anthropic(); - // Combine vault tools with built-in web search tool + // Combine vault tools with built-in web search tool (v2 - added name field) const vaultTools = getVaultToolDefinitions(); - const tools: (Anthropic.Tool | { type: string; name?: string; max_uses?: number })[] = [ + const tools: (Anthropic.Tool | { type: string; name: string; max_uses?: number })[] = [ ...vaultTools as Anthropic.Tool[], - // Built-in web search tool - { type: 'web_search_20250305', max_uses: 5 }, + // Built-in web search tool - requires type and name fields + { type: 'web_search_20250305', name: 'web_search', max_uses: 5 }, ]; const messages: ConversationMessage[] = []; From 91ac39a6651fec109b3b5b94f95d15bc7a838758 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:06:09 -0500 Subject: [PATCH 14/40] Add MCP SSE client support for external tool servers Enables the backend to connect to external MCP servers via SSE transport, discover their tools, and forward tool calls from Claude. This allows cookbook search tools (and any future MCP servers) to work through the backend provider, which is required for mobile support. - New src/mcp-client.ts: MCP client manager using @modelcontextprotocol/sdk - Modified agent.ts: merges MCP tools with vault tools, routes tool calls - Modified index.ts: initializes MCP client after server starts (non-blocking) - Added @modelcontextprotocol/sdk dependency Configured via MCP_SERVERS env var (JSON): {"server-name":{"type":"sse","url":"https://example.com/mcp/sse"}} Co-Authored-By: Claude Opus 4.6 --- backend/package-lock.json | 1036 +++++++++++++++++++++++++++++++++++++ backend/package.json | 1 + backend/src/agent.ts | 18 +- backend/src/index.ts | 9 + backend/src/mcp-client.ts | 190 +++++++ 5 files changed, 1248 insertions(+), 6 deletions(-) create mode 100644 backend/src/mcp-client.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index a487d477..532bb1a9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@modelcontextprotocol/sdk": "^1.26.0", "dotenv": "^16.4.0", "uuid": "^10.0.0", "ws": "^8.18.0", @@ -497,6 +498,58 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@types/node": { "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", @@ -545,6 +598,44 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/agentkeepalive": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", @@ -557,12 +648,78 @@ "node": ">= 8.0.0" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -576,6 +733,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -588,6 +761,94 @@ "node": ">= 0.8" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -597,6 +858,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -623,6 +893,21 @@ "node": ">= 0.4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -710,6 +995,21 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -719,6 +1019,156 @@ "node": ">=6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -754,6 +1204,24 @@ "node": ">= 12.20" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -879,6 +1347,35 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -888,6 +1385,79 @@ "ms": "^2.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -897,6 +1467,27 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -924,6 +1515,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -964,6 +1564,146 @@ } } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -974,6 +1714,215 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -1000,6 +1949,45 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1020,6 +2008,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -1033,6 +2030,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", @@ -1058,6 +2064,27 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -1087,6 +2114,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/backend/package.json b/backend/package.json index 7a10cb02..7b15fb7c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@modelcontextprotocol/sdk": "^1.26.0", "ws": "^8.18.0", "zod": "^3.23.0", "dotenv": "^16.4.0", diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 51f5045f..3f10b83c 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -11,6 +11,7 @@ import { executeVaultTool, type ToolDefinition, } from './mcp-tools.js'; +import { mcpClientManager } from './mcp-client.js'; import { logger } from './utils.js'; import type { VaultBridge, @@ -167,14 +168,20 @@ export async function* runAgent( logger.info(`Using model: ${selectedModel}`); const client = new Anthropic(); - // Combine vault tools with built-in web search tool (v2 - added name field) + // Combine vault tools with MCP tools and built-in web search tool const vaultTools = getVaultToolDefinitions(); + const mcpTools = mcpClientManager.getToolDefinitions(); const tools: (Anthropic.Tool | { type: string; name: string; max_uses?: number })[] = [ ...vaultTools as Anthropic.Tool[], + ...mcpTools as Anthropic.Tool[], // Built-in web search tool - requires type and name fields { type: 'web_search_20250305', name: 'web_search', max_uses: 5 }, ]; + if (mcpTools.length > 0) { + logger.info(`Including ${mcpTools.length} MCP tool(s): ${mcpTools.map(t => t.name).join(', ')}`); + } + const messages: ConversationMessage[] = []; // Build system prompt with CLAUDE.md context @@ -299,11 +306,10 @@ export async function* runAgent( input: toolUse.input, }; - const result = await executeVaultTool( - toolUse.name, - toolUse.input, - bridge - ); + // Route to MCP client or vault tool handler + const result = mcpClientManager.hasTool(toolUse.name) + ? await mcpClientManager.callTool(toolUse.name, toolUse.input) + : await executeVaultTool(toolUse.name, toolUse.input, bridge); yield { type: 'tool_end', diff --git a/backend/src/index.ts b/backend/src/index.ts index 3efaa556..ee32f7bf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import 'dotenv/config'; import { startServer } from './server.js'; +import { mcpClientManager } from './mcp-client.js'; import { logger } from './utils.js'; const MOCK_MODE = process.env.MOCK_MODE === 'true'; @@ -75,6 +76,14 @@ function main() { const server = startServer(); setupGracefulShutdown(server); + // Connect to external MCP servers after server is listening + // (non-blocking so healthcheck passes while SSE connects) + mcpClientManager.initialize().then(() => { + logger.info('MCP client initialization complete'); + }).catch((e) => { + logger.error('MCP client initialization failed (non-fatal):', e); + }); + logger.info('Backend ready'); } diff --git a/backend/src/mcp-client.ts b/backend/src/mcp-client.ts new file mode 100644 index 00000000..afb17a45 --- /dev/null +++ b/backend/src/mcp-client.ts @@ -0,0 +1,190 @@ +/** + * MCP Client Manager + * + * Connects to external MCP servers via SSE transport, + * discovers their tools, and forwards tool calls. + * + * Configuration via MCP_SERVERS environment variable: + * MCP_SERVERS='{"server-name":{"type":"sse","url":"https://example.com/mcp/sse"}}' + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { logger } from './utils.js'; +import type { ToolDefinition, ToolResult } from './mcp-tools.js'; + +interface McpServerConfig { + type: 'sse'; + url: string; + headers?: Record; +} + +interface McpToolEntry { + name: string; + serverName: string; + definition: ToolDefinition; +} + +export class McpClientManager { + private clients = new Map(); + private transports = new Map(); + private toolMap = new Map(); // toolName -> serverName + private toolEntries: McpToolEntry[] = []; + + /** + * Initialize MCP client connections from MCP_SERVERS env var + */ + async initialize(): Promise { + const serversJson = process.env.MCP_SERVERS; + if (!serversJson) { + logger.info('No MCP_SERVERS configured, skipping MCP client setup'); + return; + } + + let servers: Record; + try { + servers = JSON.parse(serversJson); + } catch (e) { + logger.error('Failed to parse MCP_SERVERS env var:', e); + return; + } + + for (const [name, config] of Object.entries(servers)) { + try { + await this.connectServer(name, config); + } catch (e) { + logger.error(`Failed to connect to MCP server "${name}":`, e); + } + } + + logger.info( + `MCP client initialized: ${this.clients.size} server(s), ${this.toolEntries.length} tool(s)` + ); + } + + private async connectServer( + name: string, + config: McpServerConfig + ): Promise { + logger.info(`Connecting to MCP server: ${name} at ${config.url}`); + + const url = new URL(config.url); + const transport = new SSEClientTransport(url, { + requestInit: config.headers + ? { headers: config.headers } + : undefined, + }); + + const client = new Client({ + name: 'claudsidian-backend', + version: '1.0.0', + }); + + await client.connect(transport); + this.clients.set(name, client); + this.transports.set(name, transport); + + // Discover tools + const toolsResult = await client.listTools(); + for (const tool of toolsResult.tools) { + const definition: ToolDefinition = { + name: tool.name, + description: tool.description || '', + input_schema: tool.inputSchema as ToolDefinition['input_schema'], + }; + + this.toolEntries.push({ + name: tool.name, + serverName: name, + definition, + }); + this.toolMap.set(tool.name, name); + logger.info(` Discovered tool: ${tool.name}`); + } + + logger.info( + `Connected to MCP server "${name}": ${toolsResult.tools.length} tool(s)` + ); + } + + /** + * Get tool definitions for all connected MCP servers + */ + getToolDefinitions(): ToolDefinition[] { + return this.toolEntries.map((t) => t.definition); + } + + /** + * Check if a tool name belongs to an MCP server + */ + hasTool(name: string): boolean { + return this.toolMap.has(name); + } + + /** + * Execute a tool call on the appropriate MCP server + */ + async callTool( + name: string, + input: Record + ): Promise { + const serverName = this.toolMap.get(name); + if (!serverName) { + return { content: `Unknown MCP tool: ${name}`, isError: true }; + } + + const client = this.clients.get(serverName); + if (!client) { + return { + content: `MCP server "${serverName}" not connected`, + isError: true, + }; + } + + try { + const result = await client.callTool({ + name, + arguments: input, + }); + + // MCP returns content as an array of content blocks + const textContent = (result.content as Array<{ type: string; text?: string }>) + .filter((c) => c.type === 'text' && c.text) + .map((c) => c.text!) + .join('\n'); + + return { + content: textContent || '(empty result)', + isError: result.isError === true, + }; + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + logger.error(`MCP tool "${name}" on server "${serverName}" failed:`, message); + return { + content: `Error calling ${name}: ${message}`, + isError: true, + }; + } + } + + /** + * Close all MCP client connections + */ + async close(): Promise { + for (const [name, client] of this.clients) { + try { + await client.close(); + logger.info(`Disconnected from MCP server: ${name}`); + } catch (e) { + logger.warn(`Error closing MCP client "${name}":`, e); + } + } + this.clients.clear(); + this.transports.clear(); + this.toolMap.clear(); + this.toolEntries = []; + } +} + +// Singleton instance +export const mcpClientManager = new McpClientManager(); From 4b10f1c0538d0f8c642a5a4b338e18eb15822881 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:09:06 -0500 Subject: [PATCH 15/40] Migrate backend to Claude Agent SDK with Opus 4.6 default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace @anthropic-ai/sdk + manual agent loop with @anthropic-ai/claude-agent-sdk. SDK's query() handles tool execution, message history, and MCP connections automatically. Auth now supports CLAUDE_CODE_OAUTH_TOKEN (subscription) or ANTHROPIC_API_KEY. Default model updated to claude-opus-4-6. - Rewrite agent.ts: replace manual streaming loop with SDK query() - New vault-tools.ts: Zod v4 schemas + tool() + createSdkMcpServer() - Delete mcp-client.ts (SDK handles MCP connections internally) - Delete mcp-tools.ts (replaced by vault-tools.ts) - Update index.ts: remove MCP client init, dual auth check - Upgrade zod ^3.23 → ^4.0 (Agent SDK peer dependency) Co-Authored-By: Claude Opus 4.6 --- backend/.env.example | 15 +- backend/package-lock.json | 780 ++++++++++++++++++++----------------- backend/package.json | 10 +- backend/src/agent.ts | 272 +++++-------- backend/src/index.ts | 23 +- backend/src/mcp-client.ts | 190 --------- backend/src/mcp-tools.ts | 354 ----------------- backend/src/mock-agent.ts | 2 +- backend/src/vault-tools.ts | 299 ++++++++++++++ 9 files changed, 837 insertions(+), 1108 deletions(-) delete mode 100644 backend/src/mcp-client.ts delete mode 100644 backend/src/mcp-tools.ts create mode 100644 backend/src/vault-tools.ts diff --git a/backend/.env.example b/backend/.env.example index 46840b36..94d0c46c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,9 @@ -# Anthropic API Key (required) -ANTHROPIC_API_KEY=sk-ant-... +# Claude Agent SDK auth (pick one): +# Option 1: Subscription auth (Claude Pro/Max) - preferred +# Generate via: claude setup-token +CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... +# Option 2: API key (pay-per-use) +# ANTHROPIC_API_KEY=sk-ant-... # Authentication token for WebSocket connections # Generate with: openssl rand -hex 32 @@ -8,8 +12,11 @@ AUTH_TOKEN=your-secret-token-here # Server port (default: 3001) PORT=3001 -# Claude model to use (default: claude-sonnet-4-20250514) -CLAUDE_MODEL=claude-sonnet-4-20250514 +# Claude model to use (default: claude-sonnet-4-5-20250514) +CLAUDE_MODEL=claude-opus-4-6 # Log level: debug, info, warn, error (default: info) LOG_LEVEL=info + +# External MCP servers (JSON, optional) +# MCP_SERVERS={"cookbook-research":{"type":"sse","url":"https://cookbook-rag-production.up.railway.app/mcp/sse"}} diff --git a/backend/package-lock.json b/backend/package-lock.json index 532bb1a9..cd2f93e3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,14 +8,14 @@ "name": "obsidian-claude-agent-backend", "version": "1.0.0", "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", - "@modelcontextprotocol/sdk": "^1.26.0", + "@anthropic-ai/claude-agent-sdk": "^0.2.38", "dotenv": "^16.4.0", "uuid": "^10.0.0", "ws": "^8.18.0", - "zod": "^3.23.0" + "zod": "^4.0.0" }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^22.0.0", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.0", @@ -26,36 +26,28 @@ "node": ">=20.0.0" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", - "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", - "license": "MIT", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.39", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.39.tgz", + "integrity": "sha512-wR1TBH62X6E1YwRnWa+A2Eau7AfpTWtfpnwQXO3yRY31FtmzOjPkQb93hbF3AkT0WL7YF9mxBBwJKUa3ZEc5+A==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^4.0.0" } }, - "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -502,6 +494,7 @@ "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -510,10 +503,296 @@ "hono": "^4" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "dev": true, "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -554,21 +833,12 @@ "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -586,22 +856,11 @@ "@types/node": "*" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -611,47 +870,11 @@ "node": ">= 0.6" } }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -668,6 +891,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -681,16 +905,11 @@ } } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -715,6 +934,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -724,6 +944,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -737,34 +958,24 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -778,6 +989,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -787,6 +999,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -796,6 +1009,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.6.0" @@ -805,6 +1019,7 @@ "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -822,6 +1037,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -836,6 +1052,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -849,19 +1066,11 @@ } } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -883,6 +1092,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -897,12 +1107,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -912,6 +1124,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -921,6 +1134,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -930,6 +1144,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -938,21 +1153,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -999,30 +1199,24 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -1035,6 +1229,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -1044,6 +1239,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -1087,6 +1283,7 @@ "version": "8.2.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "dev": true, "license": "MIT", "dependencies": { "ip-address": "10.0.1" @@ -1101,41 +1298,18 @@ "express": ">= 4.11" } }, - "node_modules/express/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, "funding": [ { "type": "github", @@ -1152,6 +1326,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -1169,45 +1344,11 @@ "url": "https://opencollective.com/express" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1217,6 +1358,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1241,6 +1383,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1250,6 +1393,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1274,6 +1418,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -1300,6 +1445,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1312,6 +1458,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1320,25 +1467,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1351,6 +1484,7 @@ "version": "4.11.9", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1360,6 +1494,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -1376,19 +1511,11 @@ "url": "https://opencollective.com/express" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1405,12 +1532,14 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -1420,6 +1549,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -1429,18 +1559,21 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1450,18 +1583,21 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1471,6 +1607,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1480,6 +1617,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1489,85 +1627,54 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1577,6 +1684,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1589,6 +1697,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -1601,6 +1710,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -1610,6 +1720,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1619,6 +1730,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1628,6 +1740,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -1638,6 +1751,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -1647,6 +1761,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -1660,6 +1775,7 @@ "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1675,6 +1791,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1684,6 +1801,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1699,6 +1817,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1718,6 +1837,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -1734,12 +1854,14 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -1762,35 +1884,11 @@ "url": "https://opencollective.com/express" } }, - "node_modules/send/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -1810,12 +1908,14 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -1828,6 +1928,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1837,6 +1938,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1856,6 +1958,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1872,6 +1975,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -1890,6 +1994,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -1909,6 +2014,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1918,17 +2024,12 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1953,6 +2054,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -1963,31 +2065,6 @@ "node": ">= 0.6" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2006,12 +2083,14 @@ "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/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2034,40 +2113,17 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" } }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2083,6 +2139,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -2107,9 +2164,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -2119,6 +2176,7 @@ "version": "3.25.1", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "dev": true, "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/backend/package.json b/backend/package.json index 7b15fb7c..7e1a0344 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,14 +15,14 @@ "test:auto": "tsx test/automated-test.ts" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", - "@modelcontextprotocol/sdk": "^1.26.0", - "ws": "^8.18.0", - "zod": "^3.23.0", + "@anthropic-ai/claude-agent-sdk": "^0.2.38", "dotenv": "^16.4.0", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "ws": "^8.18.0", + "zod": "^4.0.0" }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^22.0.0", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.0", diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 3f10b83c..645ffd7e 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -1,17 +1,12 @@ /** * Claude Agent Integration * - * Implements the agent loop using the Anthropic SDK with streaming - * and tool use support for vault operations. + * Implements the agent using the Claude Agent SDK's query() function, + * which handles the tool execution loop automatically. */ -import Anthropic from '@anthropic-ai/sdk'; -import { - getVaultToolDefinitions, - executeVaultTool, - type ToolDefinition, -} from './mcp-tools.js'; -import { mcpClientManager } from './mcp-client.js'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createVaultMcpServer } from './vault-tools.js'; import { logger } from './utils.js'; import type { VaultBridge, @@ -30,7 +25,7 @@ const BASE_SYSTEM_PROMPT = `You are an Obsidian note-editing assistant. You help - List files and folders - Rename/move notes (vault_rename) - Delete notes (ask for confirmation first) -- Search the web for current information (web_search) - useful for looking up documentation, news, or any external information +- Search the web for current information (WebSearch) - useful for looking up documentation, news, or any external information ## Guidelines 1. When editing existing notes, ALWAYS read them first to understand current content @@ -144,17 +139,11 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { return systemPrompt; } -const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-5-20251101'; -const MAX_TOKENS = 4096; -const MAX_ITERATIONS = 10; // Prevent infinite tool loops - -interface ConversationMessage { - role: 'user' | 'assistant'; - content: string | Anthropic.ContentBlock[]; -} +const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; +const MAX_TURNS = 10; /** - * Run the agent with streaming responses + * Run the agent with streaming responses using the Claude Agent SDK */ export async function* runAgent( prompt: string, @@ -166,200 +155,129 @@ export async function* runAgent( ): AsyncGenerator { const selectedModel = model || DEFAULT_MODEL; logger.info(`Using model: ${selectedModel}`); - const client = new Anthropic(); - - // Combine vault tools with MCP tools and built-in web search tool - const vaultTools = getVaultToolDefinitions(); - const mcpTools = mcpClientManager.getToolDefinitions(); - const tools: (Anthropic.Tool | { type: string; name: string; max_uses?: number })[] = [ - ...vaultTools as Anthropic.Tool[], - ...mcpTools as Anthropic.Tool[], - // Built-in web search tool - requires type and name fields - { type: 'web_search_20250305', name: 'web_search', max_uses: 5 }, - ]; - - if (mcpTools.length > 0) { - logger.info(`Including ${mcpTools.length} MCP tool(s): ${mcpTools.map(t => t.name).join(', ')}`); - } - const messages: ConversationMessage[] = []; + // Shared event queue — tool handlers push tool_end events here + const eventQueue: AgentEvent[] = []; + const vaultServer = createVaultMcpServer(bridge, eventQueue); + + // AbortController for the SDK (forward external signal) + const abortController = new AbortController(); + if (signal) { + signal.addEventListener('abort', () => abortController.abort()); + } // Build system prompt with CLAUDE.md context let systemPrompt = await buildSystemPrompt(bridge); // Prepend custom system prompt if provided (from user settings) - if (customSystemPrompt && customSystemPrompt.trim()) { + if (customSystemPrompt?.trim()) { systemPrompt = `${customSystemPrompt.trim()}\n\n${systemPrompt}`; } // Build context-aware prompt let fullPrompt = prompt; if (context?.currentFile) { - fullPrompt = `[Currently viewing: ${context.currentFile}]\n\n${prompt}`; + fullPrompt = `[Currently viewing: ${context.currentFile}]\n\n${fullPrompt}`; } if (context?.selection) { fullPrompt = `[Selected text: "${context.selection}"]\n\n${fullPrompt}`; } - messages.push({ role: 'user', content: fullPrompt }); - - let iteration = 0; - let continueLoop = true; + // Build MCP server configs: vault tools (in-process) + external servers from env + const mcpServers: Record = { 'vault-tools': vaultServer }; + try { + const externalServers = JSON.parse(process.env.MCP_SERVERS || '{}'); + Object.assign(mcpServers, externalServers); + } catch (e) { + logger.error('Failed to parse MCP_SERVERS env var:', e); + } - while (continueLoop && iteration < MAX_ITERATIONS) { - if (signal?.aborted) { - yield { type: 'complete', result: 'Cancelled by user' }; - return; - } + // Build allowed tools list — all MCP tools + web search + const allowedTools: string[] = Object.keys(mcpServers).map(name => `mcp__${name}__*`); + allowedTools.push('WebSearch'); - iteration++; - logger.debug(`Agent iteration ${iteration}`); + // Streaming input mode (required for mcpServers) + async function* singlePrompt() { + yield { + type: 'user' as const, + message: { role: 'user' as const, content: fullPrompt }, + parent_tool_use_id: null, + session_id: '', + }; + } - try { - // Create streaming request - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stream = await client.messages.stream({ + try { + for await (const message of query({ + prompt: singlePrompt(), + options: { model: selectedModel, - max_tokens: MAX_TOKENS, - system: systemPrompt, - tools: tools as any, // Mix of Anthropic.Tool and built-in tool types - messages: messages as Anthropic.MessageParam[], - }); - - let currentText = ''; - const toolUses: Array<{ - id: string; - name: string; - input: Record; - }> = []; - - // Process streaming events - for await (const event of stream) { - if (signal?.aborted) { - yield { type: 'complete', result: 'Cancelled by user' }; - return; - } + systemPrompt, + mcpServers: mcpServers as Record, + allowedTools, + maxTurns: MAX_TURNS, + abortController, + permissionMode: 'bypassPermissions' as const, + includePartialMessages: true, + }, + })) { + // Drain tool_end events pushed by tool handlers + while (eventQueue.length > 0) { + yield eventQueue.shift()!; + } - if (event.type === 'content_block_delta') { - const delta = event.delta; - if ('text' in delta && delta.text) { - currentText += delta.text; - yield { type: 'text_delta', text: delta.text }; - } else if ('partial_json' in delta) { - // Tool input is being streamed - we'll handle it when complete - } - } else if (event.type === 'content_block_start') { - const block = event.content_block; - if (block.type === 'tool_use') { - toolUses.push({ - id: block.id, - name: block.name, - input: {}, - }); + switch (message.type) { + case 'stream_event': { + const event = message.event; + if (event.type === 'content_block_delta' && 'text' in event.delta) { + yield { type: 'text_delta', text: (event.delta as any).text }; } - } else if (event.type === 'message_delta') { - // Message is complete + break; } - } - // Get the final message to extract complete tool inputs - const finalMessage = await stream.finalMessage(); - - // Build assistant message content - const assistantContent: Anthropic.ContentBlock[] = []; - - for (const block of finalMessage.content) { - if (block.type === 'text') { - assistantContent.push(block); - } else if (block.type === 'tool_use') { - assistantContent.push(block); - // Update tool input from final message - const toolUse = toolUses.find((t) => t.id === block.id); - if (toolUse) { - toolUse.input = block.input as Record; - } else { - toolUses.push({ - id: block.id, - name: block.name, - input: block.input as Record, - }); + case 'assistant': { + // Emit tool_start for each tool_use block in the assistant message + for (const block of message.message.content) { + if (block.type === 'tool_use') { + yield { + type: 'tool_start', + name: block.name, + input: block.input as Record, + }; + } } + break; } - } - - // Add assistant message to history - messages.push({ role: 'assistant', content: assistantContent }); - // Check if we need to execute tools - if (finalMessage.stop_reason === 'tool_use' && toolUses.length > 0) { - const toolResults: Anthropic.ToolResultBlockParam[] = []; - - for (const toolUse of toolUses) { - if (signal?.aborted) { - yield { type: 'complete', result: 'Cancelled by user' }; - return; + case 'result': { + // Drain any remaining tool events + while (eventQueue.length > 0) { + yield eventQueue.shift()!; } - yield { - type: 'tool_start', - name: toolUse.name, - input: toolUse.input, - }; - - // Route to MCP client or vault tool handler - const result = mcpClientManager.hasTool(toolUse.name) - ? await mcpClientManager.callTool(toolUse.name, toolUse.input) - : await executeVaultTool(toolUse.name, toolUse.input, bridge); - - yield { - type: 'tool_end', - name: toolUse.name, - result: result.content, - }; - - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - content: result.content, - is_error: result.isError, - }); + if (message.subtype === 'success') { + yield { type: 'complete', result: message.result || '' }; + } else { + const errors = 'errors' in message ? (message as any).errors : []; + yield { + type: 'error', + code: message.subtype, + message: errors?.join(', ') || 'Agent SDK error', + }; + } + break; } - // Add tool results to messages - messages.push({ role: 'user', content: toolResults as unknown as string }); - - // Continue the loop to get the next response - continueLoop = true; - } else { - // Agent is done - continueLoop = false; - yield { type: 'complete', result: currentText }; + // Ignore other message types (system init, user replay, etc.) + default: + break; } - } catch (err) { - logger.error('Agent error:', err); - - if (err instanceof Anthropic.APIError) { - yield { - type: 'error', - code: `API_ERROR_${err.status}`, - message: err.message, - }; - } else { - yield { - type: 'error', - code: 'AGENT_ERROR', - message: err instanceof Error ? err.message : 'Unknown error', - }; - } - return; } - } - - if (iteration >= MAX_ITERATIONS) { + } catch (err) { + logger.error('Agent error:', err); yield { type: 'error', - code: 'MAX_ITERATIONS', - message: 'Agent reached maximum iterations limit', + code: 'AGENT_ERROR', + message: err instanceof Error ? err.message : 'Unknown error', }; } } diff --git a/backend/src/index.ts b/backend/src/index.ts index ee32f7bf..6d86b339 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,22 +7,21 @@ import 'dotenv/config'; import { startServer } from './server.js'; -import { mcpClientManager } from './mcp-client.js'; import { logger } from './utils.js'; const MOCK_MODE = process.env.MOCK_MODE === 'true'; // Verify required environment variables function checkEnv() { - // In mock mode, we don't need the API key + // In mock mode, we don't need credentials if (!MOCK_MODE) { - const required = ['ANTHROPIC_API_KEY']; - const missing = required.filter((key) => !process.env[key]); - - if (missing.length > 0) { - logger.error(`Missing required environment variables: ${missing.join(', ')}`); + const hasOAuth = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + const hasApiKey = !!process.env.ANTHROPIC_API_KEY; + if (!hasOAuth && !hasApiKey) { + logger.error('Missing auth: set CLAUDE_CODE_OAUTH_TOKEN (subscription) or ANTHROPIC_API_KEY'); process.exit(1); } + logger.info(` Auth: ${hasOAuth ? 'OAuth token (subscription)' : 'API key'}`); } // Log configuration (without sensitive values) @@ -31,7 +30,7 @@ function checkEnv() { logger.info(` PORT: ${process.env.PORT || 3001}`); logger.info(` AUTH_TOKEN: ${process.env.AUTH_TOKEN ? '***' : 'dev-token (default)'}`); if (!MOCK_MODE) { - logger.info(` CLAUDE_MODEL: ${process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514'}`); + logger.info(` CLAUDE_MODEL: ${process.env.CLAUDE_MODEL || 'claude-opus-4-6'}`); } logger.info(` LOG_LEVEL: ${process.env.LOG_LEVEL || 'info'}`); } @@ -76,14 +75,6 @@ function main() { const server = startServer(); setupGracefulShutdown(server); - // Connect to external MCP servers after server is listening - // (non-blocking so healthcheck passes while SSE connects) - mcpClientManager.initialize().then(() => { - logger.info('MCP client initialization complete'); - }).catch((e) => { - logger.error('MCP client initialization failed (non-fatal):', e); - }); - logger.info('Backend ready'); } diff --git a/backend/src/mcp-client.ts b/backend/src/mcp-client.ts deleted file mode 100644 index afb17a45..00000000 --- a/backend/src/mcp-client.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * MCP Client Manager - * - * Connects to external MCP servers via SSE transport, - * discovers their tools, and forwards tool calls. - * - * Configuration via MCP_SERVERS environment variable: - * MCP_SERVERS='{"server-name":{"type":"sse","url":"https://example.com/mcp/sse"}}' - */ - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { logger } from './utils.js'; -import type { ToolDefinition, ToolResult } from './mcp-tools.js'; - -interface McpServerConfig { - type: 'sse'; - url: string; - headers?: Record; -} - -interface McpToolEntry { - name: string; - serverName: string; - definition: ToolDefinition; -} - -export class McpClientManager { - private clients = new Map(); - private transports = new Map(); - private toolMap = new Map(); // toolName -> serverName - private toolEntries: McpToolEntry[] = []; - - /** - * Initialize MCP client connections from MCP_SERVERS env var - */ - async initialize(): Promise { - const serversJson = process.env.MCP_SERVERS; - if (!serversJson) { - logger.info('No MCP_SERVERS configured, skipping MCP client setup'); - return; - } - - let servers: Record; - try { - servers = JSON.parse(serversJson); - } catch (e) { - logger.error('Failed to parse MCP_SERVERS env var:', e); - return; - } - - for (const [name, config] of Object.entries(servers)) { - try { - await this.connectServer(name, config); - } catch (e) { - logger.error(`Failed to connect to MCP server "${name}":`, e); - } - } - - logger.info( - `MCP client initialized: ${this.clients.size} server(s), ${this.toolEntries.length} tool(s)` - ); - } - - private async connectServer( - name: string, - config: McpServerConfig - ): Promise { - logger.info(`Connecting to MCP server: ${name} at ${config.url}`); - - const url = new URL(config.url); - const transport = new SSEClientTransport(url, { - requestInit: config.headers - ? { headers: config.headers } - : undefined, - }); - - const client = new Client({ - name: 'claudsidian-backend', - version: '1.0.0', - }); - - await client.connect(transport); - this.clients.set(name, client); - this.transports.set(name, transport); - - // Discover tools - const toolsResult = await client.listTools(); - for (const tool of toolsResult.tools) { - const definition: ToolDefinition = { - name: tool.name, - description: tool.description || '', - input_schema: tool.inputSchema as ToolDefinition['input_schema'], - }; - - this.toolEntries.push({ - name: tool.name, - serverName: name, - definition, - }); - this.toolMap.set(tool.name, name); - logger.info(` Discovered tool: ${tool.name}`); - } - - logger.info( - `Connected to MCP server "${name}": ${toolsResult.tools.length} tool(s)` - ); - } - - /** - * Get tool definitions for all connected MCP servers - */ - getToolDefinitions(): ToolDefinition[] { - return this.toolEntries.map((t) => t.definition); - } - - /** - * Check if a tool name belongs to an MCP server - */ - hasTool(name: string): boolean { - return this.toolMap.has(name); - } - - /** - * Execute a tool call on the appropriate MCP server - */ - async callTool( - name: string, - input: Record - ): Promise { - const serverName = this.toolMap.get(name); - if (!serverName) { - return { content: `Unknown MCP tool: ${name}`, isError: true }; - } - - const client = this.clients.get(serverName); - if (!client) { - return { - content: `MCP server "${serverName}" not connected`, - isError: true, - }; - } - - try { - const result = await client.callTool({ - name, - arguments: input, - }); - - // MCP returns content as an array of content blocks - const textContent = (result.content as Array<{ type: string; text?: string }>) - .filter((c) => c.type === 'text' && c.text) - .map((c) => c.text!) - .join('\n'); - - return { - content: textContent || '(empty result)', - isError: result.isError === true, - }; - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error'; - logger.error(`MCP tool "${name}" on server "${serverName}" failed:`, message); - return { - content: `Error calling ${name}: ${message}`, - isError: true, - }; - } - } - - /** - * Close all MCP client connections - */ - async close(): Promise { - for (const [name, client] of this.clients) { - try { - await client.close(); - logger.info(`Disconnected from MCP server: ${name}`); - } catch (e) { - logger.warn(`Error closing MCP client "${name}":`, e); - } - } - this.clients.clear(); - this.transports.clear(); - this.toolMap.clear(); - this.toolEntries = []; - } -} - -// Singleton instance -export const mcpClientManager = new McpClientManager(); diff --git a/backend/src/mcp-tools.ts b/backend/src/mcp-tools.ts deleted file mode 100644 index 3014dfb9..00000000 --- a/backend/src/mcp-tools.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** - * MCP Tool Definitions for Vault Operations - * - * Defines the tools that the Claude agent can use to interact - * with the Obsidian vault through the plugin. - */ - -import type { VaultBridge } from './protocol.js'; -import { logger, truncate } from './utils.js'; - -/** - * Tool definition for Claude's tool_use - */ -export interface ToolDefinition { - name: string; - description: string; - input_schema: { - type: 'object'; - properties: Record; - required?: string[]; - }; -} - -/** - * Tool execution result - */ -export interface ToolResult { - content: string; - isError?: boolean; -} - -/** - * Get all vault tool definitions - */ -export function getVaultToolDefinitions(): ToolDefinition[] { - return [ - { - name: 'vault_read', - description: - 'Read the content of a note from the vault. Returns the full markdown content including frontmatter. Use this before editing any existing note.', - input_schema: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path relative to vault root, e.g. "folder/note.md" or "note.md"', - }, - }, - required: ['path'], - }, - }, - { - name: 'vault_write', - description: - 'Write content to a note. Creates the file if it does not exist, overwrites if it does. Parent folders are created automatically. Always read a note first before overwriting it.', - input_schema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Path relative to vault root', - }, - content: { - type: 'string', - description: 'Full markdown content to write', - }, - }, - required: ['path', 'content'], - }, - }, - { - name: 'vault_edit', - description: - 'Make precise edits to an existing note by replacing a specific string. More efficient than rewriting entire file. The old_string must match exactly (including whitespace).', - input_schema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Path to the note to edit', - }, - old_string: { - type: 'string', - description: 'Exact text to find and replace (must be unique in file)', - }, - new_string: { - type: 'string', - description: 'Text to replace it with', - }, - }, - required: ['path', 'old_string', 'new_string'], - }, - }, - { - name: 'vault_search', - description: - 'Search for notes by content or filename. Returns matching file paths with content snippets. Useful for finding relevant notes before reading them.', - input_schema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search query - matches against filenames and content', - }, - limit: { - type: 'number', - description: 'Maximum results to return (default: 20)', - }, - }, - required: ['query'], - }, - }, - { - name: 'vault_grep', - description: - 'Search file contents using a regex pattern. More powerful than vault_search for pattern matching. Returns matching lines with context.', - input_schema: { - type: 'object', - properties: { - pattern: { - type: 'string', - description: 'Regular expression pattern to search for', - }, - folder: { - type: 'string', - description: 'Folder to search in (empty for entire vault)', - }, - file_pattern: { - type: 'string', - description: 'Glob pattern to filter files, e.g. "*.md" (default: all markdown files)', - }, - limit: { - type: 'number', - description: 'Maximum results to return (default: 50)', - }, - }, - required: ['pattern'], - }, - }, - { - name: 'vault_glob', - description: - 'Find files matching a glob pattern. Use this to discover files by name pattern, e.g. "**/*.md" for all markdown files, "projects/*.md" for markdown in projects folder.', - input_schema: { - type: 'object', - properties: { - pattern: { - type: 'string', - description: 'Glob pattern, e.g. "**/*.md", "daily/*.md", "projects/**/*"', - }, - }, - required: ['pattern'], - }, - }, - { - name: 'vault_list', - description: - 'List files and folders in a directory. Use empty string or "/" for vault root.', - input_schema: { - type: 'object', - properties: { - folder: { - type: 'string', - description: 'Folder path relative to vault root, empty for root', - }, - }, - required: [], - }, - }, - { - name: 'vault_rename', - description: - 'Rename or move a note to a new path. Updates any internal links pointing to this file if possible.', - input_schema: { - type: 'object', - properties: { - old_path: { - type: 'string', - description: 'Current path of the note', - }, - new_path: { - type: 'string', - description: 'New path for the note', - }, - }, - required: ['old_path', 'new_path'], - }, - }, - { - name: 'vault_delete', - description: - 'Delete a note from the vault. The file will be moved to system trash. Use with caution - always confirm with user first before deleting.', - input_schema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Path of the note to delete', - }, - }, - required: ['path'], - }, - }, - ]; -} - -/** - * Execute a vault tool with the given input - */ -export async function executeVaultTool( - toolName: string, - input: Record, - bridge: VaultBridge -): Promise { - logger.debug(`Executing tool: ${toolName}`, input); - - try { - switch (toolName) { - case 'vault_read': { - const path = input.path as string; - const content = await bridge.read(path); - return { - content: content, - }; - } - - case 'vault_write': { - const path = input.path as string; - const content = input.content as string; - await bridge.write(path, content); - return { - content: `Successfully wrote ${content.length} characters to ${path}`, - }; - } - - case 'vault_search': { - const query = input.query as string; - const limit = (input.limit as number) || 20; - const results = await bridge.search(query, limit); - - if (results.length === 0) { - return { - content: 'No matching notes found.', - }; - } - - const formatted = results - .map((r) => `- ${r.path}: ${truncate(r.snippet, 100)}`) - .join('\n'); - return { - content: `Found ${results.length} result(s):\n${formatted}`, - }; - } - - case 'vault_list': { - const folder = (input.folder as string) || ''; - const items = await bridge.list(folder); - - if (items.length === 0) { - return { - content: folder - ? `Folder "${folder}" is empty.` - : 'Vault is empty.', - }; - } - - const formatted = items - .map((i) => `- ${i.type === 'folder' ? '📁' : '📄'} ${i.name}`) - .join('\n'); - return { - content: `Contents of ${folder || 'vault root'}:\n${formatted}`, - }; - } - - case 'vault_delete': { - const path = input.path as string; - await bridge.delete(path); - return { - content: `Deleted ${path}`, - }; - } - - case 'vault_edit': { - const path = input.path as string; - const oldString = input.old_string as string; - const newString = input.new_string as string; - await bridge.edit(path, oldString, newString); - return { - content: `Successfully edited ${path}`, - }; - } - - case 'vault_grep': { - const pattern = input.pattern as string; - const folder = (input.folder as string) || ''; - const filePattern = (input.file_pattern as string) || '*.md'; - const limit = (input.limit as number) || 50; - const results = await bridge.grep(pattern, folder, filePattern, limit); - - if (results.length === 0) { - return { - content: 'No matches found.', - }; - } - - const formatted = results - .map((r) => `${r.path}:${r.line}: ${truncate(r.content, 100)}`) - .join('\n'); - return { - content: `Found ${results.length} match(es):\n${formatted}`, - }; - } - - case 'vault_glob': { - const pattern = input.pattern as string; - const files = await bridge.glob(pattern); - - if (files.length === 0) { - return { - content: 'No files matched the pattern.', - }; - } - - const formatted = files.map((f) => `- ${f}`).join('\n'); - return { - content: `Found ${files.length} file(s):\n${formatted}`, - }; - } - - case 'vault_rename': { - const oldPath = input.old_path as string; - const newPath = input.new_path as string; - await bridge.rename(oldPath, newPath); - return { - content: `Renamed ${oldPath} → ${newPath}`, - }; - } - - default: - return { - content: `Unknown tool: ${toolName}`, - isError: true, - }; - } - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - logger.error(`Tool ${toolName} failed:`, message); - return { - content: `Error: ${message}`, - isError: true, - }; - } -} diff --git a/backend/src/mock-agent.ts b/backend/src/mock-agent.ts index 206c9bea..4ae70c3b 100644 --- a/backend/src/mock-agent.ts +++ b/backend/src/mock-agent.ts @@ -11,7 +11,7 @@ import type { AgentContext, AgentEvent, } from './protocol.js'; -import { executeVaultTool } from './mcp-tools.js'; +import { executeVaultTool } from './vault-tools.js'; const MOCK_DELAY_MS = 50; // Delay between streaming chunks diff --git a/backend/src/vault-tools.ts b/backend/src/vault-tools.ts new file mode 100644 index 00000000..da34fb7d --- /dev/null +++ b/backend/src/vault-tools.ts @@ -0,0 +1,299 @@ +/** + * Vault Tool Definitions for Claude Agent SDK + * + * Defines vault tools using the Agent SDK's tool() + createSdkMcpServer() + * pattern with Zod schemas for input validation. + */ + +import { tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { logger, truncate } from './utils.js'; +import type { VaultBridge, AgentEvent } from './protocol.js'; + +/** + * Tool execution result (kept for mock-agent.ts compatibility) + */ +export interface ToolResult { + content: string; + isError?: boolean; +} + +/** + * Execute a vault tool by name (used by mock-agent.ts) + */ +export async function executeVaultTool( + toolName: string, + input: Record, + bridge: VaultBridge +): Promise { + logger.debug(`Executing tool: ${toolName}`, input); + + try { + switch (toolName) { + case 'vault_read': { + const path = input.path as string; + const content = await bridge.read(path); + return { content }; + } + + case 'vault_write': { + const path = input.path as string; + const content = input.content as string; + await bridge.write(path, content); + return { content: `Successfully wrote ${content.length} characters to ${path}` }; + } + + case 'vault_edit': { + const path = input.path as string; + const oldString = input.old_string as string; + const newString = input.new_string as string; + await bridge.edit(path, oldString, newString); + return { content: `Successfully edited ${path}` }; + } + + case 'vault_search': { + const query = input.query as string; + const limit = (input.limit as number) || 20; + const results = await bridge.search(query, limit); + if (results.length === 0) return { content: 'No matching notes found.' }; + const formatted = results + .map((r) => `- ${r.path}: ${truncate(r.snippet, 100)}`) + .join('\n'); + return { content: `Found ${results.length} result(s):\n${formatted}` }; + } + + case 'vault_grep': { + const pattern = input.pattern as string; + const folder = (input.folder as string) || ''; + const filePattern = (input.file_pattern as string) || '*.md'; + const limit = (input.limit as number) || 50; + const results = await bridge.grep(pattern, folder, filePattern, limit); + if (results.length === 0) return { content: 'No matches found.' }; + const formatted = results + .map((r) => `${r.path}:${r.line}: ${truncate(r.content, 100)}`) + .join('\n'); + return { content: `Found ${results.length} match(es):\n${formatted}` }; + } + + case 'vault_glob': { + const pattern = input.pattern as string; + const files = await bridge.glob(pattern); + if (files.length === 0) return { content: 'No files matched the pattern.' }; + const formatted = files.map((f) => `- ${f}`).join('\n'); + return { content: `Found ${files.length} file(s):\n${formatted}` }; + } + + case 'vault_list': { + const folder = (input.folder as string) || ''; + const items = await bridge.list(folder); + if (items.length === 0) { + return { content: folder ? `Folder "${folder}" is empty.` : 'Vault is empty.' }; + } + const formatted = items + .map((i) => `- ${i.type === 'folder' ? '📁' : '📄'} ${i.name}`) + .join('\n'); + return { content: `Contents of ${folder || 'vault root'}:\n${formatted}` }; + } + + case 'vault_rename': { + const oldPath = input.old_path as string; + const newPath = input.new_path as string; + await bridge.rename(oldPath, newPath); + return { content: `Renamed ${oldPath} → ${newPath}` }; + } + + case 'vault_delete': { + const path = input.path as string; + await bridge.delete(path); + return { content: `Deleted ${path}` }; + } + + default: + return { content: `Unknown tool: ${toolName}`, isError: true }; + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + logger.error(`Tool ${toolName} failed:`, message); + return { content: `Error: ${message}`, isError: true }; + } +} + +/** + * Create an SDK MCP server with all vault tools bound to a VaultBridge. + * Tool handlers push tool_end events to the shared queue for the agent generator. + */ +export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent[]) { + return createSdkMcpServer({ + name: 'vault-tools', + version: '1.0.0', + tools: [ + tool( + 'vault_read', + 'Read the content of a note from the vault. Returns the full markdown content including frontmatter. Use this before editing any existing note.', + { + path: z.string().describe('Path relative to vault root, e.g. "folder/note.md" or "note.md"'), + }, + async (args) => { + const content = await bridge.read(args.path); + eventQueue.push({ type: 'tool_end', name: 'vault_read', result: content }); + return { content: [{ type: 'text' as const, text: content }] }; + } + ), + + tool( + 'vault_write', + 'Write content to a note. Creates the file if it does not exist, overwrites if it does. Parent folders are created automatically. Always read a note first before overwriting it.', + { + path: z.string().describe('Path relative to vault root'), + content: z.string().describe('Full markdown content to write'), + }, + async (args) => { + await bridge.write(args.path, args.content); + const result = `Successfully wrote ${args.content.length} characters to ${args.path}`; + eventQueue.push({ type: 'tool_end', name: 'vault_write', result }); + return { content: [{ type: 'text' as const, text: result }] }; + } + ), + + tool( + 'vault_edit', + 'Make precise edits to an existing note by replacing a specific string. More efficient than rewriting entire file. The old_string must match exactly (including whitespace).', + { + path: z.string().describe('Path to the note to edit'), + old_string: z.string().describe('Exact text to find and replace (must be unique in file)'), + new_string: z.string().describe('Text to replace it with'), + }, + async (args) => { + await bridge.edit(args.path, args.old_string, args.new_string); + const result = `Successfully edited ${args.path}`; + eventQueue.push({ type: 'tool_end', name: 'vault_edit', result }); + return { content: [{ type: 'text' as const, text: result }] }; + } + ), + + tool( + 'vault_search', + 'Search for notes by content or filename. Returns matching file paths with content snippets. Useful for finding relevant notes before reading them.', + { + query: z.string().describe('Search query - matches against filenames and content'), + limit: z.number().optional().describe('Maximum results to return (default: 20)'), + }, + async (args) => { + const limit = args.limit ?? 20; + const results = await bridge.search(args.query, limit); + let result: string; + if (results.length === 0) { + result = 'No matching notes found.'; + } else { + const formatted = results + .map((r) => `- ${r.path}: ${truncate(r.snippet, 100)}`) + .join('\n'); + result = `Found ${results.length} result(s):\n${formatted}`; + } + eventQueue.push({ type: 'tool_end', name: 'vault_search', result }); + return { content: [{ type: 'text' as const, text: result }] }; + } + ), + + tool( + 'vault_grep', + 'Search file contents using a regex pattern. More powerful than vault_search for pattern matching. Returns matching lines with context.', + { + pattern: z.string().describe('Regular expression pattern to search for'), + folder: z.string().optional().describe('Folder to search in (empty for entire vault)'), + file_pattern: z.string().optional().describe('Glob pattern to filter files, e.g. "*.md" (default: all markdown files)'), + limit: z.number().optional().describe('Maximum results to return (default: 50)'), + }, + async (args) => { + const folder = args.folder || ''; + const filePattern = args.file_pattern || '*.md'; + const limit = args.limit ?? 50; + const results = await bridge.grep(args.pattern, folder, filePattern, limit); + let result: string; + if (results.length === 0) { + result = 'No matches found.'; + } else { + const formatted = results + .map((r) => `${r.path}:${r.line}: ${truncate(r.content, 100)}`) + .join('\n'); + result = `Found ${results.length} match(es):\n${formatted}`; + } + eventQueue.push({ type: 'tool_end', name: 'vault_grep', result }); + return { content: [{ type: 'text' as const, text: result }] }; + } + ), + + tool( + 'vault_glob', + 'Find files matching a glob pattern. Use this to discover files by name pattern, e.g. "**/*.md" for all markdown files, "projects/*.md" for markdown in projects folder.', + { + pattern: z.string().describe('Glob pattern, e.g. "**/*.md", "daily/*.md", "projects/**/*"'), + }, + async (args) => { + const files = await bridge.glob(args.pattern); + let result: string; + if (files.length === 0) { + result = 'No files matched the pattern.'; + } else { + const formatted = files.map((f) => `- ${f}`).join('\n'); + result = `Found ${files.length} file(s):\n${formatted}`; + } + eventQueue.push({ type: 'tool_end', name: 'vault_glob', result }); + return { content: [{ type: 'text' as const, text: result }] }; + } + ), + + tool( + 'vault_list', + 'List files and folders in a directory. Use empty string or "/" for vault root.', + { + folder: z.string().optional().describe('Folder path relative to vault root, empty for root'), + }, + async (args) => { + const folder = args.folder || ''; + const items = await bridge.list(folder); + let result: string; + if (items.length === 0) { + result = folder ? `Folder "${folder}" is empty.` : 'Vault is empty.'; + } else { + const formatted = items + .map((i) => `- ${i.type === 'folder' ? '📁' : '📄'} ${i.name}`) + .join('\n'); + result = `Contents of ${folder || 'vault root'}:\n${formatted}`; + } + eventQueue.push({ type: 'tool_end', name: 'vault_list', result }); + return { content: [{ type: 'text' as const, text: result }] }; + } + ), + + tool( + 'vault_rename', + 'Rename or move a note to a new path. Updates any internal links pointing to this file if possible.', + { + old_path: z.string().describe('Current path of the note'), + new_path: z.string().describe('New path for the note'), + }, + async (args) => { + await bridge.rename(args.old_path, args.new_path); + const result = `Renamed ${args.old_path} → ${args.new_path}`; + eventQueue.push({ type: 'tool_end', name: 'vault_rename', result }); + return { content: [{ type: 'text' as const, text: result }] }; + } + ), + + tool( + 'vault_delete', + 'Delete a note from the vault. The file will be moved to system trash. Use with caution - always confirm with user first before deleting.', + { + path: z.string().describe('Path of the note to delete'), + }, + async (args) => { + await bridge.delete(args.path); + const result = `Deleted ${args.path}`; + eventQueue.push({ type: 'tool_end', name: 'vault_delete', result }); + return { content: [{ type: 'text' as const, text: result }] }; + } + ), + ], + }); +} From 91047571a22a2e9eecc69313559f390227c1e1ca Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:47:00 -0500 Subject: [PATCH 16/40] Add git to Docker image, improve error logging for Agent SDK The Agent SDK CLI requires git at runtime. Also set HOME=/tmp/claude-home for writable config directory in Docker, and log full error stacks. Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 10 +++++++++- backend/src/agent.ts | 7 +++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index c08115e6..96ff5c31 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,8 +1,14 @@ FROM node:22-slim -# Cache bust: 2026-01-16-v2 +# Cache bust: 2026-02-11-v1 WORKDIR /app +# Install system dependencies required by Claude Agent SDK CLI +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + # Copy package files COPY package*.json ./ @@ -22,6 +28,8 @@ RUN npm prune --production # Set environment ENV NODE_ENV=production ENV PORT=3001 +# Ensure HOME is writable for Claude Agent SDK CLI config +ENV HOME=/tmp/claude-home # Expose port EXPOSE 3001 diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 645ffd7e..d68a1151 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -273,11 +273,14 @@ export async function* runAgent( } } } catch (err) { - logger.error('Agent error:', err); + const errMsg = err instanceof Error ? err.message : 'Unknown error'; + const errStack = err instanceof Error ? err.stack : ''; + logger.error('Agent error:', errMsg); + logger.error('Agent error stack:', errStack); yield { type: 'error', code: 'AGENT_ERROR', - message: err instanceof Error ? err.message : 'Unknown error', + message: errMsg, }; } } From cb127a8a0b262d4a528402c8b4146cd09ca0c852 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:53:06 -0500 Subject: [PATCH 17/40] Add CLI diagnostic on startup to debug Railway exit code 1 Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index d68a1151..241f3dae 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -142,6 +142,16 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; const MAX_TURNS = 10; +// Debug: test CLI binary on startup +import { execSync } from 'child_process'; +try { + const cliPath = new URL('../node_modules/@anthropic-ai/claude-agent-sdk/cli.js', import.meta.url).pathname; + const cliTest = execSync(`node ${cliPath} --version 2>&1`, { timeout: 10000 }).toString().trim(); + logger.info(`Agent SDK CLI test: ${cliTest}`); +} catch (e: any) { + logger.error(`Agent SDK CLI test failed: exit=${e.status} stdout=${e.stdout?.toString().substring(0, 500)} stderr=${e.stderr?.toString().substring(0, 500)}`); +} + /** * Run the agent with streaming responses using the Claude Agent SDK */ From 823317e83274f8c5b14c6914e90f199f1dd27ed9 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:03:17 -0500 Subject: [PATCH 18/40] Fix CLI debugging: add stderr capture, create HOME dir, remove startup diag - Add stderr callback to query() to capture CLI subprocess errors - Create /tmp/claude-home/.claude/ in Dockerfile so CLI has writable HOME - Remove slow startup diagnostic that blocked module load Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 1 + backend/src/agent.ts | 13 +++---------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 96ff5c31..d7d5a70f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -30,6 +30,7 @@ ENV NODE_ENV=production ENV PORT=3001 # Ensure HOME is writable for Claude Agent SDK CLI config ENV HOME=/tmp/claude-home +RUN mkdir -p /tmp/claude-home/.claude # Expose port EXPOSE 3001 diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 241f3dae..2412299b 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -142,16 +142,6 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; const MAX_TURNS = 10; -// Debug: test CLI binary on startup -import { execSync } from 'child_process'; -try { - const cliPath = new URL('../node_modules/@anthropic-ai/claude-agent-sdk/cli.js', import.meta.url).pathname; - const cliTest = execSync(`node ${cliPath} --version 2>&1`, { timeout: 10000 }).toString().trim(); - logger.info(`Agent SDK CLI test: ${cliTest}`); -} catch (e: any) { - logger.error(`Agent SDK CLI test failed: exit=${e.status} stdout=${e.stdout?.toString().substring(0, 500)} stderr=${e.stderr?.toString().substring(0, 500)}`); -} - /** * Run the agent with streaming responses using the Claude Agent SDK */ @@ -228,6 +218,9 @@ export async function* runAgent( abortController, permissionMode: 'bypassPermissions' as const, includePartialMessages: true, + stderr: (data: string) => { + logger.warn(`CLI stderr: ${data.trimEnd()}`); + }, }, })) { // Drain tool_end events pushed by tool handlers From 87e41a4db70423976d66a30fbaf085d94d4c1ff3 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:07:40 -0500 Subject: [PATCH 19/40] Fix: run container as non-root user for Agent SDK compatibility The Agent SDK CLI refuses --dangerously-skip-permissions when running as root. Create a 'claude' user and switch to it before running. Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index d7d5a70f..b953b75e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,12 +25,17 @@ RUN npm run build # Remove dev dependencies RUN npm prune --production +# Create non-root user (Agent SDK refuses --dangerously-skip-permissions as root) +RUN useradd --create-home --shell /bin/bash claude + # Set environment ENV NODE_ENV=production ENV PORT=3001 -# Ensure HOME is writable for Claude Agent SDK CLI config -ENV HOME=/tmp/claude-home -RUN mkdir -p /tmp/claude-home/.claude +ENV HOME=/home/claude +RUN mkdir -p /home/claude/.claude && chown -R claude:claude /home/claude /app + +# Switch to non-root user +USER claude # Expose port EXPOSE 3001 From 008a6ef0ffa50350b5f9b5c6c2788dad067756d1 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:23:09 -0500 Subject: [PATCH 20/40] Strip MCP prefix from tool names for cleaner plugin display Tool names like "mcp__vault-tools__vault_read" are now cleaned to just "vault_read" before sending to the plugin. This makes the activity accordion show meaningful tool names instead of MCP internals. Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 2412299b..d0a2482a 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -142,6 +142,16 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; const MAX_TURNS = 10; +/** + * Strip MCP prefix from tool names for cleaner display. + * e.g., "mcp__vault-tools__vault_read" → "vault_read" + * "mcp__cookbook-research__search_cookbooks" → "search_cookbooks" + */ +function cleanToolName(name: string): string { + const match = name.match(/^mcp__[^_]+__(.+)$/); + return match ? match[1] : name; +} + /** * Run the agent with streaming responses using the Claude Agent SDK */ @@ -243,7 +253,7 @@ export async function* runAgent( if (block.type === 'tool_use') { yield { type: 'tool_start', - name: block.name, + name: cleanToolName(block.name), input: block.input as Record, }; } @@ -270,8 +280,8 @@ export async function* runAgent( break; } - // Ignore other message types (system init, user replay, etc.) default: + logger.debug(`Unhandled SDK message type: ${message.type}`, JSON.stringify(message).substring(0, 200)); break; } } From 139238975a92df254a1d4318e15cd6d50a000514 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:30:17 -0500 Subject: [PATCH 21/40] Emit synthetic tool_end for external MCP tools The SDK handles external MCP tool execution internally, so we never got tool_end events for tools like search_cookbooks. Now we track pending tool starts and emit synthetic tool_end events when the SDK moves to the next turn (text streaming or new assistant message). This ensures the plugin's activity accordion correctly shows all tool executions with proper start/end lifecycle. Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 49 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index d0a2482a..4b420da9 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -216,6 +216,31 @@ export async function* runAgent( }; } + // Track tools that have been started but not yet ended + // (vault tools push their own tool_end via eventQueue; external MCP tools don't) + const pendingTools: string[] = []; + + function* drainToolEvents(): Generator { + while (eventQueue.length > 0) { + const event = eventQueue.shift()!; + // Remove from pending if this is a tool_end for a tracked tool + if (event.type === 'tool_end') { + const idx = pendingTools.indexOf(event.name); + if (idx >= 0) pendingTools.splice(idx, 1); + } + yield event; + } + } + + function* closePendingTools(): Generator { + // Emit synthetic tool_end for any tools that didn't get one + // (external MCP tools handled internally by the SDK) + while (pendingTools.length > 0) { + const name = pendingTools.shift()!; + yield { type: 'tool_end', name, result: '' }; + } + } + try { for await (const message of query({ prompt: singlePrompt(), @@ -233,13 +258,14 @@ export async function* runAgent( }, }, })) { - // Drain tool_end events pushed by tool handlers - while (eventQueue.length > 0) { - yield eventQueue.shift()!; - } + // Drain tool_end events pushed by vault tool handlers + yield* drainToolEvents(); switch (message.type) { case 'stream_event': { + // Text streaming means all pending tools have completed + yield* closePendingTools(); + const event = message.event; if (event.type === 'content_block_delta' && 'text' in event.delta) { yield { type: 'text_delta', text: (event.delta as any).text }; @@ -248,12 +274,17 @@ export async function* runAgent( } case 'assistant': { + // Close any pending tools from the previous turn + yield* closePendingTools(); + // Emit tool_start for each tool_use block in the assistant message for (const block of message.message.content) { if (block.type === 'tool_use') { + const name = cleanToolName(block.name); + pendingTools.push(name); yield { type: 'tool_start', - name: cleanToolName(block.name), + name, input: block.input as Record, }; } @@ -262,10 +293,9 @@ export async function* runAgent( } case 'result': { - // Drain any remaining tool events - while (eventQueue.length > 0) { - yield eventQueue.shift()!; - } + // Close any remaining pending tools + yield* drainToolEvents(); + yield* closePendingTools(); if (message.subtype === 'success') { yield { type: 'complete', result: message.result || '' }; @@ -281,7 +311,6 @@ export async function* runAgent( } default: - logger.debug(`Unhandled SDK message type: ${message.type}`, JSON.stringify(message).substring(0, 200)); break; } } From deaa2f3bcc754791d4b7e05feda10ad59f5e0c1e Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:34:17 -0500 Subject: [PATCH 22/40] Add Cursor-style Activity UI with accordion, diff blocks, and undo Replaces the old tool message display with a modern activity tracking system: - ActivityAccordion: collapsible exploration summary (auto-expand during streaming) - ActivityItem: individual activities with live timers, clickable file links - EditDiffBlock: green/red diff display with Undo button for file changes - EditHistory: singleton snapshot manager for revert functionality - Full support for cookbook search tools (search_cookbooks, list_cookbook_sources) - ActivityEvent type system with 13 activity types + generic fallback - BackendProvider emits structured ActivityEvent objects during streaming - tool-result-formatter parses raw results into structured diff/count/path data - 320 lines of Obsidian-native CSS with theme variable integration - Cleaned up debug logging artifacts from main.ts and settings.ts Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 423 ++++++++++++++++++ .../chat-view/ActivityAccordion.tsx | 244 ++++++++++ src/components/chat-view/ActivityItem.tsx | 214 +++++++++ .../AssistantToolMessageGroupItem.tsx | 256 ++++++++++- src/components/chat-view/Chat.tsx | 1 + src/components/chat-view/EditDiffBlock.tsx | 192 ++++++++ src/core/backend/BackendProvider.ts | 162 +++++-- src/core/backend/EditHistory.ts | 159 +++++++ src/core/backend/VaultRpcHandler.ts | 75 +++- src/core/backend/WebSocketClient.ts | 20 + src/core/backend/tool-result-formatter.ts | 162 ++++++- src/main.ts | 24 +- src/settings/schema/settings.ts | 3 +- src/types/chat.ts | 55 +++ src/types/llm/response.ts | 3 + src/utils/chat/responseGenerator.ts | 45 +- styles.css | 319 +++++++++++++ 17 files changed, 2279 insertions(+), 78 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/components/chat-view/ActivityAccordion.tsx create mode 100644 src/components/chat-view/ActivityItem.tsx create mode 100644 src/components/chat-view/EditDiffBlock.tsx create mode 100644 src/core/backend/EditHistory.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..374d8334 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,423 @@ +# Claudsidian - Project Context + +## Overview + +Claudsidian is a fork of [Smart Composer](https://github.com/glowingjade/obsidian-smart-composer), an Obsidian plugin for AI-assisted note-taking. This fork adds a custom backend server that enables Claude to directly interact with the Obsidian vault through tool calls, providing a Cursor-like agentic experience. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Obsidian Plugin (Frontend) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ Chat UI │ │ Backend │ │ VaultRpcHandler │ │ +│ │ Components │◄─┤ Provider │◄─┤ (executes vault ops) │ │ +│ └─────────────┘ └──────┬──────┘ └─────────────────────────┘ │ +└──────────────────────────┼──────────────────────────────────────┘ + │ WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend Server (Node.js) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ server.ts │ │ agent.ts │ │ mcp-tools.ts │ │ +│ │ (WebSocket) │◄─┤ (Claude AI) │◄─┤ (tool definitions) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Communication Flow + +1. User sends prompt via Chat UI +2. `BackendProvider` sends prompt to backend server via WebSocket +3. Backend's `agent.ts` calls Claude API with vault tools +4. When Claude uses a tool, backend sends RPC request to plugin +5. `VaultRpcHandler` executes the operation (read/write/edit/etc.) +6. Result sent back to backend, which continues the conversation +7. Activity events (`tool_start`/`tool_end`) streamed to UI for display + +## Key Directories + +``` +src/ +├── components/chat-view/ # React UI components +│ ├── Chat.tsx # Main chat container +│ ├── ActivityAccordion.tsx # Cursor-style activity display +│ ├── ActivityItem.tsx # Individual activity row +│ ├── EditDiffBlock.tsx # Diff preview for edits +│ └── AssistantToolMessageGroupItem.tsx # Message grouping +├── core/ +│ ├── backend/ # Backend integration +│ │ ├── BackendProvider.ts # LLM provider for backend +│ │ ├── WebSocketClient.ts # WebSocket communication +│ │ ├── VaultRpcHandler.ts # Vault operation executor +│ │ ├── EditHistory.ts # Undo/revert functionality +│ │ └── tool-result-formatter.ts # Format tool results +│ ├── llm/ # Other LLM providers (OpenAI, etc.) +│ └── mcp/ # Model Context Protocol support +├── types/ +│ ├── chat.ts # Chat message types, ActivityEvent +│ └── llm/ # LLM request/response types +└── utils/chat/ + └── responseGenerator.ts # Merges activities into messages + +backend/src/ +├── server.ts # WebSocket server, message routing +├── agent.ts # Claude API integration, tool loop +├── mcp-tools.ts # Vault tool definitions +└── protocol.ts # Message type definitions +``` + +## Activity UI System (Cursor-style) + +The activity UI shows tool operations in a collapsible accordion format: + +### Key Types (`src/types/chat.ts`) + +```typescript +interface ActivityEvent { + id: string + type: 'vault_read' | 'vault_write' | 'vault_edit' | 'vault_list' | + 'vault_search' | 'vault_grep' | 'vault_glob' | 'vault_rename' | + 'vault_delete' | 'web_search' | 'thinking' | 'tool_call' + status: 'running' | 'complete' | 'error' + startTime: number + endTime?: number + toolName?: string + toolInput?: Record + toolResult?: string + filePath?: string + diff?: { additions: number; deletions: number; oldContent?: string; newContent?: string } + // ... more fields +} +``` + +### Activity Flow + +1. `BackendProvider.onToolStart()` creates activity with `status: 'running'` +2. Activity added to chunk as `delta.activity` +3. `responseGenerator.mergeActivities()` collects activities into `message.activities` +4. `ActivityAccordion` renders collapsed summary (e.g., "Explored 5 files, 3 searches") +5. `ActivityItem` renders individual items with expand/collapse +6. `EditDiffBlock` shows diffs for write/edit operations with undo button + +### Content Filtering + +`AssistantToolMessageGroupItem.tsx` has `getDisplayContent()` which: +- Hides short content (<200 chars) that only contains tool result summaries +- Strips `**Read:**`, `**Created:**` etc. patterns from longer content +- Prevents duplicate display (activities shown in accordion, not as text) + +## Vault Tools + +Defined in `backend/src/mcp-tools.ts`, executed via `VaultRpcHandler`: + +| Tool | Description | Activity Type | +|------|-------------|---------------| +| `vault_read` | Read file contents | `vault_read` | +| `vault_write` | Create new file | `vault_write` | +| `vault_edit` | Edit existing file (find/replace) | `vault_edit` | +| `vault_list` | List directory contents | `vault_list` | +| `vault_search` | Search vault content | `vault_search` | +| `vault_grep` | Regex search in files | `vault_grep` | +| `vault_glob` | Find files by pattern | `vault_glob` | +| `vault_rename` | Rename/move file | `vault_rename` | +| `vault_delete` | Delete file | `vault_delete` | + +### Web Search Limitation + +`web_search` uses Claude's built-in tool (`web_search_20250305`), which is handled by Anthropic's API server-side. It does NOT go through our `tool_start`/`tool_end` event system, so no activity accordion is shown for web searches. + +## WebSocket Protocol + +### Message Types (Backend → Plugin) + +```typescript +// Tool execution request +{ type: 'rpc_request', id: string, method: string, params: object } + +// Streaming events +{ type: 'tool_start', name: string, input: object, requestId: string } +{ type: 'tool_end', name: string, result: string, requestId: string } +{ type: 'text_delta', text: string, requestId: string } +{ type: 'complete', result: string, requestId: string } +``` + +### Message Types (Plugin → Backend) + +```typescript +// Start conversation +{ type: 'prompt', prompt: string, context?: object, model?: string } + +// Tool result +{ type: 'rpc_response', id: string, result?: any, error?: object } +``` + +## Edit History & Undo + +`EditHistory.ts` tracks file modifications: +- Stores original content before edits +- Keyed by `activityId` +- `revertEdit(activityId)` restores original content +- Undo button in `EditDiffBlock` calls revert + +## Settings Schema + +Settings use versioned migrations (`src/settings/schema/migrations/`): +- Current version: 12 +- Provider configs stored in `providers[]` array +- Backend provider requires `backendUrl` and `authToken` + +## Development + +### Build Commands + +```bash +npm run build # Production build +npm run dev # Development with watch +npx tsc --noEmit # Type check only +``` + +### Testing in Obsidian + +1. Build: `npm run build` +2. Copy to vault: `cp main.js manifest.json styles.css ~/your-vault/.obsidian/plugins/claudsidian/` +3. Reload Obsidian or toggle plugin + +### Backend Development + +```bash +cd backend +npm run dev # Start with hot reload +npm run build # Production build +``` + +Backend deployed to Railway at `wss://claudsidian-production.up.railway.app` + +## E2E Testing (Autonomous UI Testing) + +For fully autonomous E2E testing without user intervention, use AppleScript to control Obsidian. + +### Test Vaults + +**IMPORTANT: Always use `yes-chef-test` for testing, NOT the live `yes-chef` vault.** + +Available test vaults (check `~/Library/Application Support/obsidian/obsidian.json` for paths): + +| Vault | Path | Purpose | +|-------|------|---------| +| `yes-chef-test` | `~/yes-chef-test/` | **Primary test vault** - use this for all testing | +| `test-plugin-vault` | `~/test-plugin-vault/` | Clean vault for plugin testing | +| `yes-chef` | `~/yes-chef/` | **LIVE vault - DO NOT use for testing** | + +### Deploy Plugin to Vault + +```bash +# Build the plugin +npm run build + +# Copy to TEST vault (yes-chef-test) +cp main.js ~/yes-chef-test/.obsidian/plugins/claudsidian/ +cp manifest.json ~/yes-chef-test/.obsidian/plugins/claudsidian/ +cp styles.css ~/yes-chef-test/.obsidian/plugins/claudsidian/ +``` + +### Launch Obsidian with Specific Vault + +```bash +# Open TEST vault by name +open -a Obsidian "obsidian://open?vault=yes-chef-test" + +# Wait for Obsidian to start +sleep 3 +``` + +### AppleScript Automation + +#### Focus Obsidian Window + +```bash +osascript << 'EOF' +tell application "Obsidian" to activate +tell application "System Events" + tell process "Obsidian" + set frontmost to true + -- Find and raise specific vault window (adjust title as needed) + -- Use: osascript -e 'tell app "System Events" to tell process "Obsidian" to name of every window' + -- to find the exact window title + set win to window 1 + perform action "AXRaise" of win + end tell +end tell +EOF +``` + +#### Open Command Palette and Run Command + +```bash +osascript << 'EOF' +tell application "System Events" + keystroke "p" using command down -- Cmd+P opens command palette + delay 0.5 + keystroke "claudsidian" -- Filter to Claudsidian commands + delay 0.5 + key code 36 -- Enter to select "Open chat" +end tell +EOF +``` + +#### Type in Chat Input and Send + +```bash +osascript << 'EOF' +tell application "System Events" + tell process "Obsidian" + set frontmost to true + delay 0.3 + -- Click in chat input area (adjust coordinates based on window position) + click at {600, 700} + delay 0.3 + end tell + -- Type the prompt + keystroke "List all files in the menus folder" + delay 0.3 + key code 36 -- Enter to send +end tell +EOF +``` + +#### Get Window Position/Size + +```bash +osascript << 'EOF' +tell application "System Events" + tell process "Obsidian" + set win to window 1 + set winPos to position of win + set winSize to size of win + log "Position: " & (item 1 of winPos) & ", " & (item 2 of winPos) + log "Size: " & (item 1 of winSize) & ", " & (item 2 of winSize) + end tell +end tell +EOF +``` + +### Taking Screenshots + +```bash +# Create screenshot directory +mkdir -p ./test-screenshots + +# Take full screen screenshot +screencapture ./test-screenshots/screen-001.png + +# View screenshot (Claude can read images) +# Use the Read tool on the PNG file to see it +``` + +### Full Autonomous Test Workflow + +```bash +# 1. Build and deploy to TEST vault +npm run build +cp main.js manifest.json styles.css ~/yes-chef-test/.obsidian/plugins/claudsidian/ + +# 2. Launch Obsidian with TEST vault +open -a Obsidian "obsidian://open?vault=yes-chef-test" +sleep 3 + +# 3. Open chat via command palette +osascript -e 'tell application "System Events" to keystroke "p" using command down' +sleep 0.5 +osascript -e 'tell application "System Events" to keystroke "claudsidian open chat"' +sleep 0.5 +osascript -e 'tell application "System Events" to key code 36' +sleep 2 + +# 4. Send test prompt +osascript << 'EOF' +tell application "System Events" + tell process "Obsidian" + click at {600, 700} + delay 0.3 + end tell + keystroke "Create a test file called activity-test.md with Hello World" + key code 36 +end tell +EOF +sleep 8 + +# 5. Take screenshot of result +screencapture ./test-screenshots/test-result.png +``` + +### Test Prompts for Each Tool + +| Tool | Test Prompt | +|------|-------------| +| `vault_list` | "List all files in the menus folder" | +| `vault_read` | "Read the MANIFEST file" | +| `vault_write` | "Create a file called test.md with Hello World" | +| `vault_edit` | "Edit test.md and change Hello to Goodbye" | +| `vault_search` | "Search for files containing 'sorbet'" | +| `vault_grep` | "Use grep to find 'yuzu' in the vault" | +| `vault_delete` | "Delete the test.md file" | + +### Reloading Plugin Without Restarting Obsidian + +Use command palette: +```bash +osascript << 'EOF' +tell application "System Events" + keystroke "p" using command down + delay 0.5 + keystroke "reload app without saving" + delay 0.5 + key code 36 +end tell +EOF +``` + +Or toggle plugin in settings (slower but safer): +1. Cmd+, to open settings +2. Navigate to Community Plugins +3. Toggle Claudsidian off then on + +### Screenshot Directory + +Test screenshots are saved to `./test-screenshots/`. Example files: +- `screen-list-response.png` - vault_list activity accordion +- `screen-write-response.png` - vault_write with diff preview +- `screen-edit-response.png` - vault_edit with red/green diff + undo button + +## CSS Classes + +Activity UI classes (in `styles.css`): +- `.smtcmp-activity-accordion` - Main accordion container +- `.smtcmp-activity-header` - Clickable header with summary +- `.smtcmp-activity-list` - Expanded activity list +- `.smtcmp-activity-item` - Individual activity row +- `.smtcmp-activity-file-link` - Clickable file links +- `.smtcmp-edit-diff-block` - Diff preview container +- `.smtcmp-diff-line-add` / `.smtcmp-diff-line-remove` - Diff highlighting + +## Known Issues & Gotchas + +1. **Web search no activity**: Claude's built-in web_search doesn't emit tool events +2. **Edit batching**: Multiple rapid edits to same file are batched (5s window) +3. **Activity merging**: Activities are merged by ID across streaming chunks +4. **Content stripping**: Short tool-only responses are hidden entirely when activities present +5. **PGlite environment**: Uses browser shims for Node.js modules (see DEVELOPMENT.md) + +## Important Files to Know + +| File | Purpose | +|------|---------| +| `src/main.ts` | Plugin entry point, registers providers | +| `src/core/backend/BackendProvider.ts` | Handles backend communication, activity events | +| `src/core/backend/WebSocketClient.ts` | WebSocket connection management | +| `src/core/backend/VaultRpcHandler.ts` | Executes vault operations from RPC | +| `src/components/chat-view/ActivityAccordion.tsx` | Activity summary UI | +| `src/components/chat-view/EditDiffBlock.tsx` | Diff display with undo | +| `src/utils/chat/responseGenerator.ts` | Merges streaming chunks, activities | +| `backend/src/agent.ts` | Claude API calls, tool execution loop | +| `backend/src/server.ts` | WebSocket server, routes messages | diff --git a/src/components/chat-view/ActivityAccordion.tsx b/src/components/chat-view/ActivityAccordion.tsx new file mode 100644 index 00000000..825a8b1c --- /dev/null +++ b/src/components/chat-view/ActivityAccordion.tsx @@ -0,0 +1,244 @@ +/** + * ActivityAccordion - Cursor-style collapsible activity section + * + * Shows live streaming of tool calls and thinking with: + * - Auto-expand during streaming + * - Live elapsed time counter for running activities + * - Collapsed summary after completion + */ + +import { + ChevronDown, + ChevronRight, + FileText, + FilePlus, + FileEdit, + Search, + Code, + FolderSearch, + Folder, + FileSymlink, + Trash2, + Globe, + Brain, + Wrench, + BookOpen, + Library, +} from 'lucide-react' +import { memo, useEffect, useMemo, useState } from 'react' + +import type { ActivityEvent } from '../../types/chat' + +import ActivityItem from './ActivityItem' + +export interface ActivityAccordionProps { + activities: ActivityEvent[] + isStreaming: boolean +} + +/** + * Get icon for activity type + */ +export function getActivityIcon(type: ActivityEvent['type']) { + switch (type) { + case 'vault_read': + return FileText + case 'vault_write': + return FilePlus + case 'vault_edit': + return FileEdit + case 'vault_search': + return Search + case 'vault_grep': + return Code + case 'vault_glob': + return FolderSearch + case 'vault_list': + return Folder + case 'vault_rename': + return FileSymlink + case 'vault_delete': + return Trash2 + case 'web_search': + return Globe + case 'search_cookbooks': + return BookOpen + case 'list_cookbook_sources': + return Library + case 'thinking': + return Brain + case 'tool_call': + default: + return Wrench + } +} + +/** + * Get display name for activity type + */ +function getActivityLabel(activity: ActivityEvent): string { + const displayName = activity.filePath?.split('/').pop() || activity.filePath + + switch (activity.type) { + case 'vault_read': + return `Read [[${displayName}]]` + case 'vault_write': + return `Created [[${displayName}]]` + case 'vault_edit': + return `Edited [[${displayName}]]` + case 'vault_search': + return `Searched "${activity.toolInput?.query || ''}"` + case 'vault_grep': + return `Grep /${activity.toolInput?.pattern || ''}/` + case 'vault_glob': + return `Found files matching ${activity.toolInput?.pattern || ''}` + case 'vault_list': + return `Listed ${activity.filePath || 'folder'}/` + case 'vault_rename': + return `Moved ${activity.oldPath?.split('/').pop()} → ${activity.newPath?.split('/').pop()}` + case 'vault_delete': + return `Deleted [[${displayName}]]` + case 'web_search': + return `Web search: "${activity.toolInput?.query || ''}"` + case 'search_cookbooks': + return `Cookbook search: "${activity.toolInput?.query || ''}"` + case 'list_cookbook_sources': + return 'Listed cookbook sources' + case 'thinking': + return 'Thinking' + case 'tool_call': + default: + return activity.toolName || 'Tool call' + } +} + +/** + * Generate summary for collapsed accordion + */ +function generateSummary(activities: ActivityEvent[]): string { + const counts = { + files: 0, + searches: 0, + edits: 0, + thinking: 0, + } + + for (const activity of activities) { + switch (activity.type) { + case 'vault_read': + case 'vault_list': + case 'vault_glob': + counts.files++ + break + case 'vault_search': + case 'vault_grep': + case 'web_search': + case 'search_cookbooks': + case 'list_cookbook_sources': + counts.searches++ + break + case 'vault_write': + case 'vault_edit': + case 'vault_rename': + case 'vault_delete': + counts.edits++ + break + case 'thinking': + counts.thinking++ + break + } + } + + const parts: string[] = [] + if (counts.files > 0) parts.push(`${counts.files} file${counts.files > 1 ? 's' : ''}`) + if (counts.searches > 0) + parts.push(`${counts.searches} search${counts.searches > 1 ? 'es' : ''}`) + if (counts.edits > 0) parts.push(`${counts.edits} edit${counts.edits > 1 ? 's' : ''}`) + + if (parts.length === 0) { + return `${activities.length} operation${activities.length > 1 ? 's' : ''}` + } + + return parts.join(', ') +} + +const ActivityAccordion = memo(function ActivityAccordion({ + activities, + isStreaming, +}: ActivityAccordionProps) { + const [isOpen, setIsOpen] = useState(true) + + // Auto-expand during streaming, auto-collapse after + useEffect(() => { + if (isStreaming) { + setIsOpen(true) + } else if (activities.length > 0) { + // Collapse after a short delay when streaming ends + const timer = setTimeout(() => setIsOpen(false), 500) + return () => clearTimeout(timer) + } + }, [isStreaming, activities.length]) + + const summary = useMemo(() => generateSummary(activities), [activities]) + + // Group activities: separate modify operations (edits) from exploration + const { explorationActivities, editActivities } = useMemo(() => { + const exploration: ActivityEvent[] = [] + const edits: ActivityEvent[] = [] + + for (const activity of activities) { + if ( + activity.type === 'vault_write' || + activity.type === 'vault_edit' || + activity.type === 'vault_rename' || + activity.type === 'vault_delete' + ) { + edits.push(activity) + } else { + exploration.push(activity) + } + } + + return { explorationActivities: exploration, editActivities: edits } + }, [activities]) + + if (activities.length === 0) { + return null + } + + return ( +
      + {/* Exploration section */} + {explorationActivities.length > 0 && ( +
      +
      setIsOpen(!isOpen)} + > + + {isOpen ? : } + + + {isStreaming ? 'Exploring...' : `Explored ${summary}`} + +
      + + {isOpen && ( +
      + {explorationActivities.map((activity) => ( + + ))} +
      + )} +
      + )} +
      + ) +}) + +export default ActivityAccordion diff --git a/src/components/chat-view/ActivityItem.tsx b/src/components/chat-view/ActivityItem.tsx new file mode 100644 index 00000000..c3b47ae5 --- /dev/null +++ b/src/components/chat-view/ActivityItem.tsx @@ -0,0 +1,214 @@ +/** + * ActivityItem - Individual activity entry in the accordion + * + * Shows: + * - Icon for activity type + * - Activity label (e.g., "Read [[file.md]]") + * - Live timer for running activities + * - Expandable details (params, results) + */ + +import clsx from 'clsx' +import { ChevronDown, ChevronRight, LucideIcon } from 'lucide-react' +import { memo, useCallback, useEffect, useState } from 'react' + +import type { ActivityEvent } from '../../types/chat' +import { useApp } from '../../contexts/app-context' + +export interface ActivityItemProps { + activity: ActivityEvent + label: string + icon: LucideIcon +} + +/** + * Format elapsed time as "Xs" or "Xm Xs" + */ +function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000) + if (seconds < 60) { + return `${seconds}s` + } + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}m ${remainingSeconds}s` +} + +/** + * Clickable file link component + */ +function FileLink({ filePath, displayName }: { filePath: string; displayName?: string }) { + const app = useApp() + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + if (app) { + // Open the file in Obsidian + const file = app.vault.getAbstractFileByPath(filePath) + if (file) { + app.workspace.getLeaf().openFile(file as any) + } + } + }, [app, filePath]) + + const name = displayName || filePath.split('/').pop() || filePath + + return ( + + [[{name}]] + + ) +} + +const ActivityItem = memo(function ActivityItem({ + activity, + label, + icon: Icon, +}: ActivityItemProps) { + const [isOpen, setIsOpen] = useState(false) + const [elapsed, setElapsed] = useState(0) + + // Live timer for running activities + useEffect(() => { + if (activity.status === 'running') { + const interval = setInterval(() => { + setElapsed(Date.now() - activity.startTime) + }, 1000) + return () => clearInterval(interval) + } else if (activity.endTime) { + setElapsed(activity.endTime - activity.startTime) + } + }, [activity.status, activity.startTime, activity.endTime]) + + // Check if this activity has expandable content + const hasDetails = + activity.thinkingContent || + (activity.results && activity.results.length > 0) || + activity.toolResult + + // Special handling for thinking - show timer in label + const displayLabel = + activity.type === 'thinking' + ? activity.status === 'running' + ? `Thinking for ${formatElapsed(elapsed)}...` + : `Thought for ${formatElapsed(elapsed)}` + : label + + // Add result count to label if available + const labelWithCount = + activity.resultCount !== undefined && activity.type !== 'thinking' + ? `${displayLabel} → ${activity.resultCount} result${activity.resultCount !== 1 ? 's' : ''}` + : displayLabel + + // Render label with clickable file link if applicable + const renderLabel = () => { + // For file operations, make the filename clickable + if (activity.filePath && ( + activity.type === 'vault_read' || + activity.type === 'vault_write' || + activity.type === 'vault_edit' + )) { + const fileName = activity.filePath.split('/').pop() || activity.filePath + // Replace [[filename]] pattern in label with clickable link + const parts = labelWithCount.split(/\[\[[^\]]+\]\]/) + if (parts.length > 1) { + return ( + <> + {parts[0]} + + {parts.slice(1).join('')} + + ) + } + } + return labelWithCount + } + + return ( +
      +
      hasDetails && setIsOpen(!isOpen)} + style={{ cursor: hasDetails ? 'pointer' : 'default' }} + > + + {hasDetails ? ( + isOpen ? ( + + ) : ( + + ) + ) : ( + + )} + + + + + {renderLabel()} + {activity.status === 'running' && activity.type !== 'thinking' && ( + {formatElapsed(elapsed)} + )} +
      + + {isOpen && hasDetails && ( +
      + {/* Thinking content */} + {activity.thinkingContent && ( +
      + {activity.thinkingContent} +
      + )} + + {/* Search/grep results */} + {activity.results && activity.results.length > 0 && ( +
      + {activity.results.slice(0, 10).map((result, index) => ( +
      + {/* Format as clickable file link if it looks like a file path */} + {result.includes('/') || result.endsWith('.md') ? ( + + ) : ( + {result} + )} +
      + ))} + {activity.results.length > 10 && ( +
      + +{activity.results.length - 10} more +
      + )} +
      + )} + + {/* Raw tool result for generic tool calls */} + {activity.toolResult && + !activity.thinkingContent && + !activity.results && ( +
      +
      {activity.toolResult.slice(0, 500)}
      + {activity.toolResult.length > 500 && ( + + ... ({activity.toolResult.length - 500} more chars) + + )} +
      + )} +
      + )} +
      + ) +}) + +export default ActivityItem diff --git a/src/components/chat-view/AssistantToolMessageGroupItem.tsx b/src/components/chat-view/AssistantToolMessageGroupItem.tsx index 33dd8326..8c19248d 100644 --- a/src/components/chat-view/AssistantToolMessageGroupItem.tsx +++ b/src/components/chat-view/AssistantToolMessageGroupItem.tsx @@ -1,20 +1,69 @@ +import { useMemo } from 'react' + import { + ActivityEvent, AssistantToolMessageGroup, ChatMessage, ChatToolMessage, } from '../../types/chat' +import ActivityAccordion from './ActivityAccordion' import AssistantMessageAnnotations from './AssistantMessageAnnotations' import AssistantMessageContent from './AssistantMessageContent' import AssistantMessageReasoning from './AssistantMessageReasoning' import AssistantToolMessageGroupActions from './AssistantToolMessageGroupActions' +import EditDiffBlock from './EditDiffBlock' import ToolMessage from './ToolMessage' +/** + * Check if message content is primarily just tool result descriptions. + * When we have activities displayed, these are redundant. + * Returns the content minus tool-result patterns, or empty if only tool results. + */ +function getDisplayContent(content: string, hasActivities: boolean): string { + if (!hasActivities || !content) { + return content + } + + // If the content is short (under 200 chars) and contains tool patterns, + // it's likely just tool result summaries - hide it entirely + const toolIndicatorPatterns = [ + /\*\*(Read|Created|Edited|Found|Listed|Searched|Matched|Deleted|Renamed|Grep matches|Found files):\*\*/i, + /[📖📝✏️🔍📂🔎📄🗑️➡️🌐]/, + /^(I |I've |I'm |Let me |Here's |The )(read|created|edited|found|listed|searched|deleted|renamed)/i, + ] + + const hasToolIndicators = toolIndicatorPatterns.some((p) => p.test(content)) + + if (hasToolIndicators && content.length < 200) { + return '' // Hide short tool-result-only content + } + + // For longer content, strip tool patterns but keep the rest + const stripPatterns = [ + // Bold markdown formats + /\*\*(Read|Created|Edited|Found|Listed|Searched|Matched|Deleted|Renamed|Grep matches|Found files):\*\*\s*[^\n]+\n?/gi, + // Emoji formats + /[📖📝✏️🔍📂🔎📄🗑️➡️🌐]\s*\w+:\s*[^\n]+\n?/g, + ] + + let result = content + for (const pattern of stripPatterns) { + result = result.replace(pattern, '') + } + + // Clean up extra newlines + result = result.replace(/\n{3,}/g, '\n\n').trim() + + return result +} + export type AssistantToolMessageGroupItemProps = { messages: AssistantToolMessageGroup contextMessages: ChatMessage[] conversationId: string isApplying: boolean // TODO: isApplying should be a boolean for each assistant message + isStreaming?: boolean // Whether the message is currently streaming onApply: (blockToApply: string, chatMessages: ChatMessage[]) => void onToolMessageUpdate: (message: ChatToolMessage) => void } @@ -24,14 +73,185 @@ export default function AssistantToolMessageGroupItem({ contextMessages, conversationId, isApplying, + isStreaming = false, onApply, onToolMessageUpdate, }: AssistantToolMessageGroupItemProps) { + // Collect all activities from assistant messages, tool calls, and parsed content + const { allActivities, editActivities } = useMemo(() => { + const activities: ActivityEvent[] = [] + const edits: ActivityEvent[] = [] + const seenIds = new Set() + + for (const message of messages) { + // Collect activities from assistant messages + if (message.role === 'assistant') { + // First check for explicit activity events + if (message.activities) { + for (const activity of message.activities) { + if (!seenIds.has(activity.id)) { + seenIds.add(activity.id) + activities.push(activity) + if ( + activity.type === 'vault_write' || + activity.type === 'vault_edit' || + activity.type === 'vault_rename' || + activity.type === 'vault_delete' + ) { + edits.push(activity) + } + } + } + } + + // Parse tool results from message content (for backend that embeds results in text) + // Only parse if no explicit activities were provided (to avoid duplicates) + // Patterns: "**Read:** filename", "**Listed:** X folders", "**Found:** ...", etc. + if (message.activities && message.activities.length > 0) { + continue // Skip content parsing if we have explicit activities + } + + const content = message.content || '' + const toolPatterns = [ + { pattern: /\*\*Read:\*\*\s*\[?([^\]\n]+)\]?/g, type: 'vault_read' as const }, + { pattern: /\*\*Listed:\*\*\s*([^\n]+)/g, type: 'vault_list' as const }, + { pattern: /\*\*Found:\*\*\s*([^\n]+)/g, type: 'vault_search' as const }, + { pattern: /\*\*Created:\*\*\s*([^\n]+)/g, type: 'vault_write' as const }, + { pattern: /\*\*Edited:\*\*\s*([^\n]+)/g, type: 'vault_edit' as const }, + { pattern: /\*\*Renamed:\*\*\s*([^\n]+)/g, type: 'vault_rename' as const }, + { pattern: /\*\*Deleted:\*\*\s*([^\n]+)/g, type: 'vault_delete' as const }, + { pattern: /\*\*Searched:\*\*\s*([^\n]+)/g, type: 'vault_grep' as const }, + { pattern: /\*\*Matched:\*\*\s*([^\n]+)/g, type: 'vault_glob' as const }, + ] + + for (const { pattern, type } of toolPatterns) { + let match + while ((match = pattern.exec(content)) !== null) { + const activityId = `parsed-${type}-${match.index}` + if (seenIds.has(activityId)) continue + seenIds.add(activityId) + + const resultText = match[1].trim() + const activity: ActivityEvent = { + id: activityId, + type, + status: 'complete', + startTime: Date.now(), + endTime: Date.now(), + toolName: type, + toolResult: resultText, + filePath: type === 'vault_read' || type === 'vault_write' || type === 'vault_edit' + ? resultText.split(/[,\s]/)[0] + : undefined, + } + + activities.push(activity) + + if ( + type === 'vault_write' || + type === 'vault_edit' || + type === 'vault_rename' || + type === 'vault_delete' + ) { + edits.push(activity) + } + } + } + } + + // Synthesize activities from tool messages (for MCP-based providers) + if (message.role === 'tool' && message.toolCalls) { + for (const toolCall of message.toolCalls) { + const toolName = toolCall.request.name + if (!toolName.startsWith('backend__') && !toolName.startsWith('vault_')) { + continue + } + + const activityId = `tool-${toolCall.request.id}` + if (seenIds.has(activityId)) continue + seenIds.add(activityId) + + const cleanName = toolName.replace('backend__', '') + let activityType: ActivityEvent['type'] = 'tool_call' + if (cleanName.startsWith('vault_')) { + activityType = cleanName as ActivityEvent['type'] + } + + let toolInput: Record = {} + try { + if (toolCall.request.arguments) { + toolInput = JSON.parse(toolCall.request.arguments) + } + } catch { + // Ignore parse errors + } + + let toolResult: string | undefined + if ('data' in toolCall.response && toolCall.response.data?.text) { + toolResult = toolCall.response.data.text + } else if ('error' in toolCall.response) { + toolResult = toolCall.response.error + } + + const activity: ActivityEvent = { + id: activityId, + type: activityType, + status: 'complete', + startTime: Date.now(), + endTime: Date.now(), + toolName: cleanName, + toolInput, + toolResult, + filePath: typeof toolInput.path === 'string' ? toolInput.path : undefined, + } + + activities.push(activity) + + if ( + activityType === 'vault_write' || + activityType === 'vault_edit' || + activityType === 'vault_rename' || + activityType === 'vault_delete' + ) { + edits.push(activity) + } + } + } + } + + return { allActivities: activities, editActivities: edits } + }, [messages]) + return (
      - {messages.map((message) => - message.role === 'assistant' ? ( - message.reasoning || message.annotations || message.content ? ( + {/* Activity accordion (Cursor-style) - shows before content */} + {allActivities.length > 0 && ( + + )} + + {/* Edit diff blocks for file modifications */} + {editActivities.length > 0 && ( +
      + {editActivities.map((activity) => ( + + ))} +
      + )} + + {messages.map((message) => { + if (message.role === 'assistant') { + // Get display content - filters out tool result summaries when we have activities + const displayContent = getDisplayContent( + message.content || '', + allActivities.length > 0, + ) + + // Don't render if content is empty after filtering + if (!message.reasoning && !message.annotations && !displayContent) { + return null + } + + return (
      {message.reasoning && ( @@ -41,15 +261,25 @@ export default function AssistantToolMessageGroupItem({ annotations={message.annotations} /> )} - + {displayContent && ( + + )}
      - ) : null - ) : ( + ) + } + + // For tool messages, only show if we don't have activities + // (activities replace the old ToolMessage display) + if (allActivities.length > 0) { + return null + } + + return (
      - ), - )} + ) + })} {messages.length > 0 && ( )} diff --git a/src/components/chat-view/Chat.tsx b/src/components/chat-view/Chat.tsx index e07b248a..2c403fe9 100644 --- a/src/components/chat-view/Chat.tsx +++ b/src/components/chat-view/Chat.tsx @@ -676,6 +676,7 @@ const Chat = forwardRef((props, ref) => { )} conversationId={currentConversationId} isApplying={applyMutation.isPending} + isStreaming={submitChatMutation.isPending && index === groupedChatMessages.length - 1} onApply={handleApply} onToolMessageUpdate={handleToolMessageUpdate} /> diff --git a/src/components/chat-view/EditDiffBlock.tsx b/src/components/chat-view/EditDiffBlock.tsx new file mode 100644 index 00000000..8294941f --- /dev/null +++ b/src/components/chat-view/EditDiffBlock.tsx @@ -0,0 +1,192 @@ +/** + * EditDiffBlock - Live streaming diff with revert button + * + * Shows: + * - File header with +X -Y line count + * - Diff content with green/red highlighting + * - Undo button for revert + */ + +import { ChevronDown, ChevronRight, Undo2 } from 'lucide-react' +import { memo, useCallback, useState } from 'react' + +import type { ActivityEvent } from '../../types/chat' +import { getEditHistory } from '../../core/backend/EditHistory' +import { useApp } from '../../contexts/app-context' + +export interface EditDiffBlockProps { + activity: ActivityEvent + onReverted?: () => void +} + +/** + * Simple diff display - shows old (red) and new (green) content + */ +function DiffContent({ + oldContent, + newContent, +}: { + oldContent?: string + newContent?: string +}) { + // If we have both old and new content, show a simple diff + if (oldContent && newContent) { + return ( +
      +
      + - +
      {oldContent}
      +
      +
      + + +
      {newContent}
      +
      +
      + ) + } + + // If only new content (write operation), show all as additions + if (newContent) { + const lines = newContent.split('\n').slice(0, 20) // Limit displayed lines + const hasMore = newContent.split('\n').length > 20 + return ( +
      + {lines.map((line, i) => ( +
      + + +
      {line || ' '}
      +
      + ))} + {hasMore && ( +
      + +{newContent.split('\n').length - 20} more lines +
      + )} +
      + ) + } + + // If only old content (delete operation), show all as deletions + if (oldContent) { + const lines = oldContent.split('\n').slice(0, 20) + const hasMore = oldContent.split('\n').length > 20 + return ( +
      + {lines.map((line, i) => ( +
      + - +
      {line || ' '}
      +
      + ))} + {hasMore && ( +
      + +{oldContent.split('\n').length - 20} more lines +
      + )} +
      + ) + } + + return null +} + +const EditDiffBlock = memo(function EditDiffBlock({ + activity, + onReverted, +}: EditDiffBlockProps) { + const app = useApp() + const [isOpen, setIsOpen] = useState(true) + const [isReverting, setIsReverting] = useState(false) + const [reverted, setReverted] = useState(false) + + const handleRevert = useCallback(async () => { + if (!app || isReverting || reverted) return + + setIsReverting(true) + try { + const editHistory = getEditHistory(app) + const success = await editHistory.revertByActivityId(activity.id) + if (success) { + setReverted(true) + onReverted?.() + } + } catch (error) { + console.error('[EditDiffBlock] Failed to revert:', error) + } finally { + setIsReverting(false) + } + }, [app, activity.id, isReverting, reverted, onReverted]) + + // Get display name + const displayName = activity.filePath?.split('/').pop() || activity.filePath || 'file' + + // Get diff stats + const additions = activity.diff?.additions || 0 + const deletions = activity.diff?.deletions || 0 + + // Determine operation type for display + let operationLabel = '' + switch (activity.type) { + case 'vault_write': + operationLabel = 'Created' + break + case 'vault_edit': + operationLabel = 'Edited' + break + case 'vault_rename': + operationLabel = 'Renamed' + break + case 'vault_delete': + operationLabel = 'Deleted' + break + default: + operationLabel = 'Modified' + } + + return ( +
      +
      setIsOpen(!isOpen)}> + + {isOpen ? : } + + + {operationLabel} [[{displayName}]] + + + {additions > 0 && +{additions}} + {deletions > 0 && -{deletions}} + +
      + {!reverted && ( + + )} + {reverted && ( + Reverted + )} +
      +
      + + {isOpen && activity.diff && ( +
      + +
      + )} +
      + ) +}) + +export default EditDiffBlock diff --git a/src/core/backend/BackendProvider.ts b/src/core/backend/BackendProvider.ts index a8218ce4..2f2c3834 100644 --- a/src/core/backend/BackendProvider.ts +++ b/src/core/backend/BackendProvider.ts @@ -6,7 +6,9 @@ */ import { BaseLLMProvider } from '../llm/base'; +import { LLMRateLimitExceededException } from '../llm/exception'; import type { ChatModel } from '../../types/chat-model.types'; +import type { ActivityEvent, ActivityType } from '../../types/chat'; import type { LLMOptions, LLMRequestNonStreaming, @@ -18,9 +20,30 @@ import type { ToolCallDelta, } from '../../types/llm/response'; import type { BackendProviderConfig } from '../../types/provider.types'; -import { formatToolResult } from './tool-result-formatter'; +import { parseToolResult } from './tool-result-formatter'; import type { WebSocketClient } from './WebSocketClient'; +/** + * Map tool names to activity types + */ +function getActivityType(toolName: string): ActivityType { + const mapping: Record = { + vault_read: 'vault_read', + vault_write: 'vault_write', + vault_edit: 'vault_edit', + vault_search: 'vault_search', + vault_grep: 'vault_grep', + vault_glob: 'vault_glob', + vault_list: 'vault_list', + vault_rename: 'vault_rename', + vault_delete: 'vault_delete', + web_search: 'web_search', + search_cookbooks: 'search_cookbooks', + list_cookbook_sources: 'list_cookbook_sources', + }; + return mapping[toolName] || 'tool_call'; +} + export class BackendProvider extends BaseLLMProvider { constructor( provider: BackendProviderConfig, @@ -142,10 +165,16 @@ export class BackendProvider extends BaseLLMProvider { name: string; arguments: string; result?: string; + activityId: string; } > = new Map(); let isComplete = false; - let errorOccurred = false; + // Using an object wrapper to avoid TypeScript's control flow narrowing issues in async generators + const errorState = { error: null as { code: string; message: string } | null }; + + // Track current thinking activity for timer + let currentThinkingId: string | null = null; + let thinkingStartTime: number | null = null; // Create a queue for messages const messageQueue: LLMResponseStreaming[] = []; @@ -185,14 +214,16 @@ export class BackendProvider extends BaseLLMProvider { // Create a tool call entry with backend__ prefix for UI display const index = toolCalls.size; const toolId = `backend-${requestId}-${index}`; + const activityId = `activity-${requestId}-${index}`; toolCalls.set(index, { id: toolId, name: `backend__${name}`, // Add prefix so UI knows it's from backend arguments: JSON.stringify(input), + activityId, }); - // Send tool call delta to show in UI + // Send tool call delta to show in UI (legacy) const toolDelta: ToolCallDelta = { index, id: toolId, @@ -203,13 +234,29 @@ export class BackendProvider extends BaseLLMProvider { }, }; + // Create activity event for Cursor-style UI + const activity: ActivityEvent = { + id: activityId, + type: getActivityType(name), + status: 'running', + startTime: Date.now(), + toolName: name, + toolInput: input, + filePath: (input.path as string) || (input.old_path as string), + oldPath: input.old_path as string, + newPath: input.new_path as string, + }; + enqueueChunk({ id: requestId, object: 'chat.completion.chunk', model: 'backend', choices: [ { - delta: { tool_calls: [toolDelta] }, + delta: { + tool_calls: [toolDelta], + activity, + }, finish_reason: null, }, ], @@ -217,43 +264,72 @@ export class BackendProvider extends BaseLLMProvider { }, onToolEnd: (name: string, result: string) => { - // Store result and send it to UI as clickable file references + // Store result and send completed activity for (const tool of Array.from(toolCalls.values())) { if (tool.name === `backend__${name}` && !tool.result) { tool.result = result; - // Format tool results with clickable file references - const formattedResult = formatToolResult(name, result, tool.arguments); - - // Only send formatted result if we have file references - if (formattedResult) { - enqueueChunk({ - id: requestId, - object: 'chat.completion.chunk', - model: 'backend', - choices: [ - { - delta: { - content: `\n${formattedResult}\n`, - }, - finish_reason: null, - }, - ], - }); - } + // Parse tool result to extract structured data + const parsedResult = parseToolResult(name, result, tool.arguments); + + // Create completed activity event + const activity: ActivityEvent = { + id: tool.activityId, + type: getActivityType(name), + status: parsedResult.isError ? 'error' : 'complete', + startTime: 0, // Will be merged with start event + endTime: Date.now(), + toolName: name, + toolResult: result, + errorMessage: parsedResult.isError ? result : undefined, + filePath: parsedResult.filePath, + resultCount: parsedResult.resultCount, + results: parsedResult.results, + diff: parsedResult.diff, + }; + + enqueueChunk({ + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [ + { + delta: { activity }, + finish_reason: null, + }, + ], + }); break; } } }, onThinking: (text: string) => { + // Start new thinking activity if not already active + if (!currentThinkingId) { + currentThinkingId = `thinking-${requestId}-${Date.now()}`; + thinkingStartTime = Date.now(); + } + + // Create thinking activity event + const activity: ActivityEvent = { + id: currentThinkingId, + type: 'thinking', + status: 'running', + startTime: thinkingStartTime!, + thinkingContent: text, + }; + const chunk: LLMResponseStreaming = { id: requestId, object: 'chat.completion.chunk', model: 'backend', choices: [ { - delta: { reasoning: text }, + delta: { + reasoning: text, + activity, + }, finish_reason: null, }, ], @@ -262,6 +338,30 @@ export class BackendProvider extends BaseLLMProvider { }, onComplete: (result: string) => { + // Finalize any pending thinking activity + if (currentThinkingId && thinkingStartTime) { + const thinkingActivity: ActivityEvent = { + id: currentThinkingId, + type: 'thinking', + status: 'complete', + startTime: thinkingStartTime, + endTime: Date.now(), + }; + enqueueChunk({ + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [ + { + delta: { activity: thinkingActivity }, + finish_reason: null, + }, + ], + }); + currentThinkingId = null; + thinkingStartTime = null; + } + const chunk: LLMResponseStreaming = { id: requestId, object: 'chat.completion.chunk', @@ -287,7 +387,7 @@ export class BackendProvider extends BaseLLMProvider { console.error( `[BackendProvider] Error: ${code} - ${message}` ); - errorOccurred = true; + errorState.error = { code, message }; isComplete = true; const chunk: LLMResponseStreaming = { @@ -331,8 +431,14 @@ export class BackendProvider extends BaseLLMProvider { } } - if (errorOccurred) { - throw new Error('Backend error occurred'); + if (errorState.error) { + // Check for rate limit error (API_ERROR_429) + if (errorState.error.code === 'API_ERROR_429' || errorState.error.message.includes('rate limit')) { + throw new LLMRateLimitExceededException( + errorState.error.message || 'Rate limit exceeded. Please try again later.' + ); + } + throw new Error(errorState.error.message || 'Backend error occurred'); } } diff --git a/src/core/backend/EditHistory.ts b/src/core/backend/EditHistory.ts new file mode 100644 index 00000000..31d2260c --- /dev/null +++ b/src/core/backend/EditHistory.ts @@ -0,0 +1,159 @@ +/** + * EditHistory - Tracks file snapshots for revert functionality + * + * Stores snapshots of files before edits, allowing users to + * undo changes made by the AI assistant. + */ + +import type { App } from 'obsidian'; + +export interface FileSnapshot { + path: string; + content: string; + timestamp: number; + activityId: string; // Links to ActivityEvent +} + +export class EditHistory { + private snapshots = new Map(); + private maxVersions: number; + private app: App; + + constructor(app: App, maxVersions: number = 5) { + this.app = app; + this.maxVersions = maxVersions; + } + + /** + * Record a snapshot of a file before editing + */ + recordBefore(path: string, content: string, activityId: string): void { + const snapshot: FileSnapshot = { + path, + content, + timestamp: Date.now(), + activityId, + }; + + const existing = this.snapshots.get(path) || []; + existing.push(snapshot); + + // Trim to max versions + while (existing.length > this.maxVersions) { + existing.shift(); + } + + this.snapshots.set(path, existing); + } + + /** + * Get all snapshots for a file + */ + getSnapshots(path: string): FileSnapshot[] { + return this.snapshots.get(path) || []; + } + + /** + * Get the most recent snapshot for a file + */ + getLatestSnapshot(path: string): FileSnapshot | undefined { + const snapshots = this.snapshots.get(path); + return snapshots?.[snapshots.length - 1]; + } + + /** + * Get snapshot by activity ID + */ + getSnapshotByActivityId(activityId: string): FileSnapshot | undefined { + for (const snapshots of this.snapshots.values()) { + const found = snapshots.find((s) => s.activityId === activityId); + if (found) return found; + } + return undefined; + } + + /** + * Revert a file to a specific snapshot + */ + async revert(path: string, snapshotIndex: number): Promise { + const snapshots = this.snapshots.get(path); + if (!snapshots || snapshotIndex < 0 || snapshotIndex >= snapshots.length) { + return false; + } + + const snapshot = snapshots[snapshotIndex]; + + try { + const file = this.app.vault.getAbstractFileByPath(path); + if (file && 'stat' in file) { + await this.app.vault.modify(file as any, snapshot.content); + // Remove this and all newer snapshots after revert + this.snapshots.set(path, snapshots.slice(0, snapshotIndex)); + return true; + } + return false; + } catch (error) { + console.error('[EditHistory] Failed to revert:', error); + return false; + } + } + + /** + * Revert to the snapshot associated with a specific activity + */ + async revertByActivityId(activityId: string): Promise { + for (const [path, snapshots] of this.snapshots.entries()) { + const index = snapshots.findIndex((s) => s.activityId === activityId); + if (index !== -1) { + return this.revert(path, index); + } + } + return false; + } + + /** + * Clear all snapshots for a file + */ + clear(path: string): void { + this.snapshots.delete(path); + } + + /** + * Clear all snapshots + */ + clearAll(): void { + this.snapshots.clear(); + } + + /** + * Get statistics about stored snapshots + */ + getStats(): { files: number; totalSnapshots: number } { + let totalSnapshots = 0; + for (const snapshots of this.snapshots.values()) { + totalSnapshots += snapshots.length; + } + return { + files: this.snapshots.size, + totalSnapshots, + }; + } +} + +// Singleton instance - will be initialized when app is available +let editHistoryInstance: EditHistory | null = null; + +export function getEditHistory(app?: App): EditHistory { + if (!editHistoryInstance && app) { + editHistoryInstance = new EditHistory(app); + } + if (!editHistoryInstance) { + throw new Error('EditHistory not initialized - app required'); + } + return editHistoryInstance; +} + +export function initEditHistory(app: App, maxVersions?: number): EditHistory { + editHistoryInstance = new EditHistory(app, maxVersions); + return editHistoryInstance; +} diff --git a/src/core/backend/VaultRpcHandler.ts b/src/core/backend/VaultRpcHandler.ts index 065ad24c..f3e2de30 100644 --- a/src/core/backend/VaultRpcHandler.ts +++ b/src/core/backend/VaultRpcHandler.ts @@ -7,16 +7,19 @@ import { App, TFile, TFolder, Notice } from 'obsidian'; import type { SearchResult, FileInfo, GrepResult } from './protocol'; +import { getEditHistory } from './EditHistory'; export class VaultRpcHandler { constructor(private app: App) {} /** * Handle an RPC request from the backend + * @param activityId - Optional activity ID for tracking edits */ async handleRpc( method: string, - params: Record + params: Record, + activityId?: string ): Promise { switch (method) { case 'vault_read': @@ -24,13 +27,15 @@ export class VaultRpcHandler { case 'vault_write': return this.vaultWrite( params.path as string, - params.content as string + params.content as string, + activityId ); case 'vault_edit': return this.vaultEdit( params.path as string, params.old_string as string, - params.new_string as string + params.new_string as string, + activityId ); case 'vault_search': return this.vaultSearch( @@ -51,10 +56,11 @@ export class VaultRpcHandler { case 'vault_rename': return this.vaultRename( params.old_path as string, - params.new_path as string + params.new_path as string, + activityId ); case 'vault_delete': - return this.vaultDelete(params.path as string); + return this.vaultDelete(params.path as string, activityId); default: throw new Error(`Unknown RPC method: ${method}`); } @@ -84,17 +90,31 @@ export class VaultRpcHandler { */ private async vaultWrite( path: string, - content: string + content: string, + activityId?: string ): Promise<{ success: boolean }> { const existingFile = this.app.vault.getAbstractFileByPath(path); if (existingFile instanceof TFile) { - // File exists, modify it + // File exists - record snapshot before overwriting + if (activityId) { + try { + const oldContent = await this.app.vault.read(existingFile); + getEditHistory(this.app).recordBefore(path, oldContent, activityId); + } catch (e) { + console.warn('[VaultRpcHandler] Failed to record snapshot:', e); + } + } + // Modify the file await this.app.vault.modify(existingFile, content); } else if (existingFile instanceof TFolder) { throw new Error(`Path is a folder, not a file: ${path}`); } else { // File doesn't exist, create it + // Record empty snapshot for new files (so revert = delete) + if (activityId) { + getEditHistory(this.app).recordBefore(path, '', activityId); + } // First, ensure parent folders exist const folderPath = path.substring(0, path.lastIndexOf('/')); if (folderPath) { @@ -113,7 +133,8 @@ export class VaultRpcHandler { private async vaultEdit( path: string, oldString: string, - newString: string + newString: string, + activityId?: string ): Promise<{ success: boolean }> { const file = this.app.vault.getAbstractFileByPath(path); @@ -142,6 +163,15 @@ export class VaultRpcHandler { ); } + // Record snapshot before editing + if (activityId) { + try { + getEditHistory(this.app).recordBefore(path, content, activityId); + } catch (e) { + console.warn('[VaultRpcHandler] Failed to record snapshot:', e); + } + } + // Replace the string const newContent = content.replace(oldString, newString); await this.app.vault.modify(file, newContent); @@ -349,7 +379,8 @@ export class VaultRpcHandler { */ private async vaultRename( oldPath: string, - newPath: string + newPath: string, + activityId?: string ): Promise<{ success: boolean }> { const file = this.app.vault.getAbstractFileByPath(oldPath); @@ -357,6 +388,17 @@ export class VaultRpcHandler { throw new Error(`File not found: ${oldPath}`); } + // Record snapshot before rename (stores old path for revert) + if (activityId && file instanceof TFile) { + try { + const content = await this.app.vault.read(file); + // Store with special marker to indicate this is a rename snapshot + getEditHistory(this.app).recordBefore(oldPath, content, activityId); + } catch (e) { + console.warn('[VaultRpcHandler] Failed to record snapshot:', e); + } + } + // Ensure parent folder of new path exists const newFolderPath = newPath.substring(0, newPath.lastIndexOf('/')); if (newFolderPath) { @@ -374,13 +416,26 @@ export class VaultRpcHandler { /** * Delete a file (move to trash) */ - private async vaultDelete(path: string): Promise<{ success: boolean }> { + private async vaultDelete( + path: string, + activityId?: string + ): Promise<{ success: boolean }> { const file = this.app.vault.getAbstractFileByPath(path); if (!file) { throw new Error(`File not found: ${path}`); } + // Record snapshot before delete (for potential restore) + if (activityId && file instanceof TFile) { + try { + const content = await this.app.vault.read(file); + getEditHistory(this.app).recordBefore(path, content, activityId); + } catch (e) { + console.warn('[VaultRpcHandler] Failed to record snapshot:', e); + } + } + // Move to system trash await this.app.vault.trash(file, true); diff --git a/src/core/backend/WebSocketClient.ts b/src/core/backend/WebSocketClient.ts index 5d92a5e0..5b33f18a 100644 --- a/src/core/backend/WebSocketClient.ts +++ b/src/core/backend/WebSocketClient.ts @@ -216,6 +216,26 @@ export class WebSocketClient { }); } + /** + * Emit a tool start event to all active handlers + * Note: In normal operation, tool_start/tool_end events come from the backend + * server over WebSocket, so this is mainly for testing or alternative flows. + */ + emitToolStart(toolName: string, toolInput: Record): void { + for (const handlers of this.activeHandlers.values()) { + handlers.onToolStart?.(toolName, toolInput); + } + } + + /** + * Emit a tool end event to all active handlers + */ + emitToolEnd(toolName: string, result: string): void { + for (const handlers of this.activeHandlers.values()) { + handlers.onToolEnd?.(toolName, result); + } + } + /** * Register an event handler */ diff --git a/src/core/backend/tool-result-formatter.ts b/src/core/backend/tool-result-formatter.ts index c749674c..cd46fe0d 100644 --- a/src/core/backend/tool-result-formatter.ts +++ b/src/core/backend/tool-result-formatter.ts @@ -12,12 +12,135 @@ const recentEdits: Map = new Map(); const EDIT_BATCH_WINDOW_MS = 5000; // Batch edits within 5 seconds +/** + * Structured result from parsing tool output + */ +export interface ParsedToolResult { + isError: boolean; + filePath?: string; + oldPath?: string; + newPath?: string; + resultCount?: number; + results?: string[]; + diff?: { + additions: number; + deletions: number; + oldContent?: string; + newContent?: string; + }; +} + +/** + * Parse tool result into structured data for ActivityEvent + */ +export function parseToolResult( + toolName: string, + result: string, + toolArguments: string +): ParsedToolResult { + const parsed: ParsedToolResult = { + isError: result.startsWith('Error:') || result.includes('failed'), + }; + + try { + const args = JSON.parse(toolArguments); + + if (toolName === 'vault_read') { + parsed.filePath = args.path; + } else if (toolName === 'vault_write') { + parsed.filePath = args.path; + // Count lines in content for diff stats + const content = args.content || ''; + const lines = content.split('\n').length; + parsed.diff = { + additions: lines, + deletions: 0, + newContent: content, + }; + } else if (toolName === 'vault_edit') { + parsed.filePath = args.path; + // Calculate diff from old/new strings + const oldStr = args.old_string || ''; + const newStr = args.new_string || ''; + const oldLines = oldStr ? oldStr.split('\n').length : 0; + const newLines = newStr ? newStr.split('\n').length : 0; + parsed.diff = { + additions: newLines, + deletions: oldLines, + oldContent: oldStr, + newContent: newStr, + }; + } else if (toolName === 'vault_rename') { + parsed.oldPath = args.old_path; + parsed.newPath = args.new_path; + parsed.filePath = args.new_path; + } else if (toolName === 'vault_delete') { + parsed.filePath = args.path; + } else if (toolName === 'vault_search') { + const lines = result.split('\n'); + const fileRefs: string[] = []; + for (const line of lines) { + const match = line.match(/^-\s*(.+\.\w+):\s/); + if (match) { + fileRefs.push(match[1].trim()); + } + } + parsed.resultCount = fileRefs.length; + parsed.results = fileRefs; + } else if (toolName === 'vault_grep') { + const lines = result.split('\n'); + const fileRefs: string[] = []; + const seenFiles = new Set(); + for (const line of lines) { + const match = line.match(/^(.+\.\w+):(\d+):/); + if (match && !seenFiles.has(match[1])) { + seenFiles.add(match[1]); + fileRefs.push(match[1].trim()); + } + } + parsed.resultCount = fileRefs.length; + parsed.results = fileRefs; + } else if (toolName === 'vault_glob') { + const lines = result.split('\n'); + const fileRefs: string[] = []; + for (const line of lines) { + const match = line.match(/^-\s*(.+\.\w+)$/); + if (match) { + fileRefs.push(match[1].trim()); + } + } + parsed.resultCount = fileRefs.length; + parsed.results = fileRefs; + } else if (toolName === 'vault_list') { + const lines = result.split('\n'); + let count = 0; + for (const line of lines) { + if (line.trim().startsWith('- ')) { + count++; + } + } + parsed.resultCount = count; + } else if (toolName === 'web_search') { + // Extract URLs from web search results + const urlMatches = result.match(/https?:\/\/[^\s)]+/g); + if (urlMatches) { + parsed.resultCount = urlMatches.length; + parsed.results = urlMatches; + } + } + } catch { + // If argument parsing fails, just return basic result + } + + return parsed; +} + /** * Format a collapsible file list with count summary */ function formatCollapsibleList( title: string, - icon: string, + _icon: string, // Unused, kept for backwards compatibility fileRefs: string[], maxVisible: number = 3 ): string { @@ -27,14 +150,14 @@ function formatCollapsibleList( // If few files, just show them inline if (count <= maxVisible) { - return `\n${icon} **${title}:** ${fileRefs.join(', ')}`; + return `\n**${title}:** ${fileRefs.join(', ')}`; } // For many files, show count with expandable list const visibleRefs = fileRefs.slice(0, maxVisible); const hiddenCount = count - maxVisible; - return `\n${icon} **${title}:** ${visibleRefs.join(', ')} *+${hiddenCount} more*`; + return `\n**${title}:** ${visibleRefs.join(', ')} *+${hiddenCount} more*`; } export function formatToolResult( @@ -57,7 +180,7 @@ export function formatToolResult( } } - formattedResult = formatCollapsibleList('Found', '🔍', fileRefs); + formattedResult = formatCollapsibleList('Found', '', fileRefs); } else if (toolName === 'vault_list') { const lines = result.split('\n'); const folders: string[] = []; @@ -67,12 +190,21 @@ export function formatToolResult( const trimmed = line.trim(); if (!trimmed.startsWith('- ')) continue; - if (trimmed.includes('📁')) { - const name = trimmed.replace(/^-\s*📁\s*/, '').trim(); + // Handle both emoji and non-emoji formats + if (trimmed.includes('[folder]') || trimmed.includes('folder:')) { + const name = trimmed.replace(/^-\s*(\[folder\]|folder:)?\s*/, '').trim(); folders.push(name); - } else if (trimmed.includes('📄')) { - const name = trimmed.replace(/^-\s*📄\s*/, '').trim(); + } else if (trimmed.includes('[file]') || trimmed.includes('file:')) { + const name = trimmed.replace(/^-\s*(\[file\]|file:)?\s*/, '').trim(); files.push(`[[${name}]]`); + } else { + // Fallback: treat as file if has extension, folder otherwise + const name = trimmed.replace(/^-\s*/, '').trim(); + if (name.includes('.')) { + files.push(`[[${name}]]`); + } else { + folders.push(name); + } } } @@ -81,14 +213,14 @@ export function formatToolResult( const parts: string[] = []; if (folders.length > 0) parts.push(`${folders.length} folders`); if (files.length > 0) parts.push(`${files.length} files`); - formattedResult = `\n📂 **Listed:** ${parts.join(', ')}`; + formattedResult = `\n**Listed:** ${parts.join(', ')}`; } } else if (toolName === 'vault_read') { try { const args = JSON.parse(toolArguments); if (args.path) { const displayName = args.path.split('/').pop() || args.path; - formattedResult = `\n📖 **Read:** [[${args.path}|${displayName}]]`; + formattedResult = `\n**Read:** [[${args.path}|${displayName}]]`; } } catch (e) { // Silently ignore parse errors @@ -98,7 +230,7 @@ export function formatToolResult( const args = JSON.parse(toolArguments); if (args.path) { const displayName = args.path.split('/').pop() || args.path; - formattedResult = `\n✍️ **Created:** [[${args.path}|${displayName}]]`; + formattedResult = `\n**Created:** [[${args.path}|${displayName}]]`; } } catch (e) { // Silently ignore parse errors @@ -130,7 +262,7 @@ export function formatToolResult( } } - formattedResult = `\n✏️ **Edited:** [[${args.path}|${displayName}]]`; + formattedResult = `\n**Edited:** [[${args.path}|${displayName}]]`; } } catch (e) { // Silently ignore parse errors @@ -152,7 +284,7 @@ export function formatToolResult( } } - formattedResult = formatCollapsibleList('Grep matches', '🔎', fileRefs); + formattedResult = formatCollapsibleList('Grep matches', '', fileRefs); } else if (toolName === 'vault_glob') { const lines = result.split('\n'); const fileRefs: string[] = []; @@ -166,13 +298,13 @@ export function formatToolResult( } } - formattedResult = formatCollapsibleList('Found files', '📁', fileRefs); + formattedResult = formatCollapsibleList('Found files', '', fileRefs); } else if (toolName === 'vault_rename') { try { const args = JSON.parse(toolArguments); if (args.old_path && args.new_path) { const newDisplayName = args.new_path.split('/').pop() || args.new_path; - formattedResult = `\n📝 **Renamed:** → [[${args.new_path}|${newDisplayName}]]`; + formattedResult = `\n**Renamed:** -> [[${args.new_path}|${newDisplayName}]]`; } } catch (e) { // Silently ignore parse errors diff --git a/src/main.ts b/src/main.ts index 7ae4697a..9893e6b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { getMentionableBlockData } from './utils/obsidian' import { webSocketClient } from './core/backend/instance' import { VaultRpcHandler } from './core/backend/VaultRpcHandler' import { ConflictManager } from './core/backend/ConflictManager' +import { initEditHistory } from './core/backend/EditHistory' import type { RpcRequestMessage } from './core/backend/protocol' export default class SmartComposerPlugin extends Plugin { @@ -41,27 +42,29 @@ export default class SmartComposerPlugin extends Plugin { // Initialize backend components this.conflictManager = new ConflictManager(this.app) this.vaultRpcHandler = new VaultRpcHandler(this.app) + initEditHistory(this.app, 5) // Store up to 5 versions per file for revert // Wire RPC handler to respond to backend requests + // Note: tool_start/tool_end events are sent by the backend server itself, + // so we don't need to emit them here. We just handle the RPC request. webSocketClient.on('rpc_request', async (msg: unknown) => { const rpcMsg = msg as RpcRequestMessage - console.log( - `[Claudsidian] RPC request received: ${rpcMsg.method}`, - rpcMsg.params, - ) + try { + // Pass RPC id as activityId for edit history tracking const result = await this.vaultRpcHandler!.handleRpc( rpcMsg.method, rpcMsg.params, + rpcMsg.id, // activityId for revert functionality ) - console.log(`[Claudsidian] RPC result for ${rpcMsg.method}:`, result) + webSocketClient.sendRpcResponse(rpcMsg.id, result) } catch (error) { console.error('[Claudsidian] RPC handler error:', error) + webSocketClient.sendRpcResponse(rpcMsg.id, undefined, { code: 'RPC_ERROR', - message: - error instanceof Error ? error.message : 'Unknown error', + message: error instanceof Error ? error.message : 'Unknown error', }) } }) @@ -196,8 +199,9 @@ export default class SmartComposerPlugin extends Plugin { } async loadSettings() { - this.settings = parseSmartComposerSettings(await this.loadData()) - await this.saveData(this.settings) // Save updated settings + const rawData = await this.loadData() + this.settings = parseSmartComposerSettings(rawData) + await this.saveData(this.settings) } async setSettings(newSettings: SmartComposerSettings) { @@ -385,8 +389,8 @@ ${validationResult.error.issues.map((v) => v.message).join('\n')}`) const backendProvider = this.settings.providers.find( (p) => p.type === 'backend', ) + if (!backendProvider || backendProvider.type !== 'backend') { - console.log('[SmartComposer] No backend provider configured') return } diff --git a/src/settings/schema/settings.ts b/src/settings/schema/settings.ts index db606a65..d8a2125c 100644 --- a/src/settings/schema/settings.ts +++ b/src/settings/schema/settings.ts @@ -32,7 +32,8 @@ export function parseSmartComposerSettings( ): SmartComposerSettings { try { const migratedData = migrateSettings(data as Record) - return smartComposerSettingsSchema.parse(migratedData) + const result = smartComposerSettingsSchema.parse(migratedData) + return result } catch (error) { console.warn('Invalid settings provided, using defaults:', error) return smartComposerSettingsSchema.parse({}) diff --git a/src/types/chat.ts b/src/types/chat.ts index a682bbe8..27df0d84 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -8,6 +8,59 @@ import { Annotation, ResponseUsage } from './llm/response' import { Mentionable, SerializedMentionable } from './mentionable' import { ToolCallRequest, ToolCallResponse } from './tool-call.types' +/** + * Activity types for the Cursor-style activity accordion + */ +export type ActivityType = + | 'thinking' + | 'vault_read' + | 'vault_write' + | 'vault_edit' + | 'vault_search' + | 'vault_grep' + | 'vault_glob' + | 'vault_list' + | 'vault_rename' + | 'vault_delete' + | 'web_search' + | 'search_cookbooks' + | 'list_cookbook_sources' + | 'tool_call' // Generic fallback for unknown tools + +export type ActivityStatus = 'running' | 'complete' | 'error' + +/** + * Represents a single activity event (tool call, thinking, etc.) + */ +export interface ActivityEvent { + id: string + type: ActivityType + status: ActivityStatus + startTime: number + endTime?: number + // Tool-specific fields + toolName?: string + toolInput?: Record + toolResult?: string + errorMessage?: string + // Thinking-specific + thinkingContent?: string + // File operation-specific + filePath?: string + oldPath?: string // For rename + newPath?: string // For rename + // Diff info for write/edit operations + diff?: { + additions: number + deletions: number + oldContent?: string + newContent?: string + } + // Search results + resultCount?: number + results?: string[] // File paths or search results +} + export type ChatUserMessage = { role: 'user' content: SerializedEditorState | null @@ -24,6 +77,7 @@ export type ChatAssistantMessage = { reasoning?: string annotations?: Annotation[] toolCallRequests?: ToolCallRequest[] + activities?: ActivityEvent[] // Cursor-style activity tracking id: string metadata?: { usage?: ResponseUsage @@ -65,6 +119,7 @@ export type SerializedChatAssistantMessage = { reasoning?: string annotations?: Annotation[] toolCallRequests?: ToolCallRequest[] + activities?: ActivityEvent[] // Cursor-style activity tracking id: string metadata?: { usage?: ResponseUsage diff --git a/src/types/llm/response.ts b/src/types/llm/response.ts index 16f72908..ba74c83f 100644 --- a/src/types/llm/response.ts +++ b/src/types/llm/response.ts @@ -1,6 +1,8 @@ // These types are based on the OpenRouter API specification // https://openrouter.ai/docs/api-reference/overview#responses +import type { ActivityEvent } from '../chat' + export type LLMResponseBase = { id: string created?: number @@ -47,6 +49,7 @@ type StreamingChoice = { role?: string annotations?: Annotation[] tool_calls?: ToolCallDelta[] + activity?: ActivityEvent // For Cursor-style activity streaming } error?: Error } diff --git a/src/utils/chat/responseGenerator.ts b/src/utils/chat/responseGenerator.ts index 6c552dbf..e7a106f4 100644 --- a/src/utils/chat/responseGenerator.ts +++ b/src/utils/chat/responseGenerator.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid' import { BaseLLMProvider } from '../../core/llm/base' import { McpManager } from '../../core/mcp/mcpManager' -import { ChatMessage, ChatToolMessage } from '../../types/chat' +import { ActivityEvent, ChatMessage, ChatToolMessage } from '../../types/chat' import { ChatModel } from '../../types/chat-model.types' import { RequestTool } from '../../types/llm/request' import { @@ -248,6 +248,7 @@ export class ResponseGenerator { const reasoning = chunk.choices[0]?.delta?.reasoning const toolCalls = chunk.choices[0]?.delta?.tool_calls const annotations = chunk.choices[0]?.delta?.annotations + const activity = chunk.choices[0]?.delta?.activity const updatedToolCalls = toolCalls ? this.mergeToolCallDeltas(toolCalls, responseToolCalls) @@ -292,6 +293,9 @@ export class ResponseGenerator { message.annotations, annotations, ), + activities: activity + ? this.mergeActivities(message.activities, activity) + : message.activities, metadata: { ...message.metadata, usage: chunk.usage ?? message.metadata?.usage, @@ -376,4 +380,43 @@ export class ResponseGenerator { } return mergedAnnotations } + + /** + * Merge activity events - updates existing activities or adds new ones + */ + private mergeActivities( + prevActivities?: ActivityEvent[], + newActivity?: ActivityEvent, + ): ActivityEvent[] | undefined { + if (!newActivity) return prevActivities + if (!prevActivities) return [newActivity] + + // Check if this activity already exists (by id) + const existingIndex = prevActivities.findIndex((a) => a.id === newActivity.id) + + if (existingIndex >= 0) { + // Update existing activity - merge fields + const existing = prevActivities[existingIndex] + const merged: ActivityEvent = { + ...existing, + ...newActivity, + // Preserve startTime from original + startTime: existing.startTime || newActivity.startTime, + // For thinking, append content + thinkingContent: + newActivity.type === 'thinking' && existing.thinkingContent + ? existing.thinkingContent + (newActivity.thinkingContent || '') + : newActivity.thinkingContent || existing.thinkingContent, + } + + return [ + ...prevActivities.slice(0, existingIndex), + merged, + ...prevActivities.slice(existingIndex + 1), + ] + } + + // Add new activity + return [...prevActivities, newActivity] + } } diff --git a/styles.css b/styles.css index 8e0825d7..a47f083d 100644 --- a/styles.css +++ b/styles.css @@ -1925,3 +1925,322 @@ button.smtcmp-chat-input-model-select { .smtcmp-setting-item--nested { padding-left: var(--size-4-4); } + +/* Activity Accordion (Cursor-style) */ +.smtcmp-activity-accordion { + display: flex; + flex-direction: column; + gap: var(--size-4-1); + margin-bottom: var(--size-4-2); +} + +.smtcmp-activity-section { + background: var(--background-modifier-form-field); + border: var(--input-border-width) solid var(--background-modifier-border); + border-radius: var(--radius-s); + overflow: hidden; +} + +.smtcmp-activity-accordion-header { + display: flex; + align-items: center; + gap: var(--size-4-1); + padding: var(--size-4-1) var(--size-4-2); + cursor: pointer; + user-select: none; + font-size: var(--font-ui-small); + color: var(--text-muted); + background: var(--background-secondary); + + &:hover { + background: var(--background-modifier-hover); + } +} + +.smtcmp-activity-accordion-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.smtcmp-activity-accordion-title { + flex-grow: 1; +} + +.smtcmp-activity-accordion-content { + display: flex; + flex-direction: column; + padding: var(--size-4-1); + border-top: var(--input-border-width) solid var(--background-modifier-border); +} + +/* Activity Items */ +.smtcmp-activity-item { + display: flex; + flex-direction: column; + font-size: var(--font-ui-smaller); +} + +.smtcmp-activity-item-header { + display: flex; + align-items: center; + gap: var(--size-2-2); + padding: var(--size-2-2) var(--size-4-1); + border-radius: var(--radius-s); + + &:hover { + background: var(--background-modifier-hover); + } +} + +.smtcmp-activity-item-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 12px; +} + +.smtcmp-activity-item-type-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-muted); +} + +.smtcmp-activity-item-label { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.smtcmp-activity-item-timer { + flex-shrink: 0; + color: var(--text-faint); + font-size: var(--font-smallest); + font-family: var(--font-monospace); +} + +.smtcmp-activity-item--running .smtcmp-activity-item-type-icon { + color: var(--interactive-accent); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.smtcmp-activity-item--error .smtcmp-activity-item-type-icon { + color: var(--text-error); +} + +.smtcmp-activity-item-details { + padding: var(--size-4-1) var(--size-4-2) var(--size-4-1) calc(var(--size-4-4) + 12px); + font-size: var(--font-smallest); + color: var(--text-muted); + background: var(--background-secondary); + border-radius: var(--radius-s); + margin: var(--size-2-1) 0; +} + +.smtcmp-activity-item-thinking { + white-space: pre-wrap; + line-height: 1.4; + max-height: 150px; + overflow-y: auto; +} + +.smtcmp-activity-item-results { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} + +.smtcmp-activity-item-result { + padding: var(--size-2-1) 0; +} + +.smtcmp-activity-item-file { + color: var(--text-accent); +} + +.smtcmp-activity-file-link { + color: var(--text-accent); + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; +} + +.smtcmp-activity-file-link:hover { + text-decoration-style: solid; + color: var(--text-accent-hover); +} + +.smtcmp-activity-item-more { + color: var(--text-faint); + font-style: italic; +} + +.smtcmp-activity-item-raw { + max-height: 100px; + overflow-y: auto; + + pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: var(--font-smallest); + } +} + +/* Edit Diff Block */ +.smtcmp-edit-blocks { + display: flex; + flex-direction: column; + gap: var(--size-4-1); + margin-bottom: var(--size-4-2); +} + +.smtcmp-edit-diff { + background: var(--background-modifier-form-field); + border: var(--input-border-width) solid var(--background-modifier-border); + border-radius: var(--radius-s); + overflow: hidden; +} + +.smtcmp-edit-diff-header { + display: flex; + align-items: center; + gap: var(--size-4-1); + padding: var(--size-4-1) var(--size-4-2); + cursor: pointer; + user-select: none; + font-size: var(--font-ui-small); + background: var(--background-secondary); + + &:hover { + background: var(--background-modifier-hover); + } +} + +.smtcmp-edit-diff-toggle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-muted); +} + +.smtcmp-edit-diff-filename { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-monospace); +} + +.smtcmp-edit-diff-stats { + display: flex; + gap: var(--size-4-1); + flex-shrink: 0; + font-family: var(--font-monospace); + font-size: var(--font-smallest); +} + +.smtcmp-diff-stat-add { + color: var(--text-success); +} + +.smtcmp-diff-stat-del { + color: var(--text-error); +} + +.smtcmp-edit-diff-actions { + display: flex; + align-items: center; + gap: var(--size-4-1); + margin-left: auto; +} + +.smtcmp-edit-diff-revert { + display: flex; + align-items: center; + gap: var(--size-2-2); + padding: var(--size-2-2) var(--size-4-1); + font-size: var(--font-smallest); + background: var(--background-primary-alt); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + cursor: pointer; + color: var(--text-muted); + + &:hover:not(:disabled) { + background: var(--background-modifier-hover); + color: var(--text-normal); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.smtcmp-edit-diff-reverted { + font-size: var(--font-smallest); + color: var(--text-success); + padding: var(--size-2-2) var(--size-4-1); +} + +.smtcmp-edit-diff-body { + border-top: var(--input-border-width) solid var(--background-modifier-border); + max-height: 300px; + overflow-y: auto; +} + +.smtcmp-diff-content { + font-family: var(--font-monospace); + font-size: var(--font-smallest); + line-height: 1.4; +} + +.smtcmp-diff-addition { + display: flex; + background: rgba(var(--color-green-rgb), 0.15); + + pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + padding: 0 var(--size-4-1); + } +} + +.smtcmp-diff-deletion { + display: flex; + background: rgba(var(--color-red-rgb), 0.15); + + pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + padding: 0 var(--size-4-1); + } +} + +.smtcmp-diff-marker { + flex-shrink: 0; + width: var(--size-4-4); + text-align: center; + color: var(--text-muted); + user-select: none; +} + +.smtcmp-diff-more { + padding: var(--size-4-1) var(--size-4-2); + color: var(--text-faint); + font-style: italic; +} From 0eb3c94571775bd3f991a45e64629b89c3b3c408 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:49:24 -0500 Subject: [PATCH 23/40] Fix WebSocket disconnect loop: use app-level keepalive instead of protocol-level Railway's proxy doesn't forward WebSocket ping/pong frames, causing the server's protocol-level heartbeat to terminate connections after ~45 seconds. Now tracks last message activity time and allows 90s of inactivity. The plugin already sends application-level ping messages every 25s which keep the connection alive through the proxy. Co-Authored-By: Claude Opus 4.6 --- backend/src/server.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index 9e2711fb..a1aa73ab 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -41,28 +41,30 @@ interface PendingRpc { class ConnectionHandler { private pendingRpcs = new Map(); private activeRequests = new Map(); - private isAlive = true; + private lastActivity = Date.now(); constructor(private ws: WebSocket) { - ws.on('message', (data) => this.handleMessage(data.toString())); + ws.on('message', (data) => { + this.lastActivity = Date.now(); + this.handleMessage(data.toString()); + }); ws.on('close', () => this.cleanup()); ws.on('error', (err) => logger.error('WebSocket error:', err)); - ws.on('pong', () => { - this.isAlive = true; - }); } /** - * Check if the connection is still alive (for heartbeat) + * Check if the connection is still alive based on last message activity. + * Uses application-level pings (not protocol-level) since Railway's proxy + * doesn't forward WebSocket ping/pong frames. */ checkAlive(): boolean { - if (!this.isAlive) { - logger.warn('Connection heartbeat timeout, terminating'); + const inactiveMs = Date.now() - this.lastActivity; + // Allow 90 seconds of inactivity (plugin sends app-level pings every 30s) + if (inactiveMs > 90000) { + logger.warn(`Connection inactive for ${Math.round(inactiveMs / 1000)}s, terminating`); this.ws.terminate(); return false; } - this.isAlive = false; - this.ws.ping(); return true; } From 1517455c545357df21d98bde8027f317e19b60d8 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:51:08 -0500 Subject: [PATCH 24/40] Add cookbook citation instructions to system prompt Tell the agent to always include exact page numbers and source names when citing cookbook search results, preventing vague references. Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 4b420da9..55e295d1 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -39,9 +39,18 @@ const BASE_SYSTEM_PROMPT = `You are an Obsidian note-editing assistant. You help 6. For destructive operations (delete, overwrite), confirm with the user first 7. If a search returns no results, suggest alternative search terms or use vault_grep with regex +## Cookbook Research Tools +When the user asks about cooking techniques, recipes, ingredients, or food science: +- Use search_cookbooks to find information in their cookbook collection +- ALWAYS include exact citations from the results: source book name, page numbers, and section +- Format citations like: "*The Professional Chef* (CIA), pp. 593-594" +- Include multiple sources when available for a comprehensive answer +- Quote key passages directly when they're particularly informative + ## Response Style - Be concise but helpful - Explain what changes you're making +- When citing cookbook sources, always include the exact page numbers - If uncertain, ask for clarification`; interface Skill { From 24e26f16b53aea2a0d015f6f77f570f7ef22c2fe Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:16:59 -0500 Subject: [PATCH 25/40] Fix undo button and add clickable file links in edit diffs - Fix activity ID mismatch between BackendProvider and VaultRpcHandler that prevented undo from finding the correct snapshot. Track activity IDs from tool_start events and consume them in the RPC handler. - Fix revert for new files: delete file instead of writing empty content. - Add clickable file links in EditDiffBlock that open files in editor. Co-Authored-By: Claude Opus 4.6 --- src/components/chat-view/EditDiffBlock.tsx | 21 ++++++++++- src/core/backend/BackendProvider.ts | 3 ++ src/core/backend/EditHistory.ts | 17 ++++++--- src/core/backend/WebSocketClient.ts | 42 +++++++++++++++++++++- src/main.ts | 6 ++-- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/components/chat-view/EditDiffBlock.tsx b/src/components/chat-view/EditDiffBlock.tsx index 8294941f..9bde2b8b 100644 --- a/src/components/chat-view/EditDiffBlock.tsx +++ b/src/components/chat-view/EditDiffBlock.tsx @@ -9,6 +9,7 @@ import { ChevronDown, ChevronRight, Undo2 } from 'lucide-react' import { memo, useCallback, useState } from 'react' +import { TFile } from 'obsidian' import type { ActivityEvent } from '../../types/chat' import { getEditHistory } from '../../core/backend/EditHistory' @@ -99,6 +100,14 @@ const EditDiffBlock = memo(function EditDiffBlock({ const [isReverting, setIsReverting] = useState(false) const [reverted, setReverted] = useState(false) + const handleOpenFile = useCallback(() => { + if (!app || !activity.filePath) return + const file = app.vault.getAbstractFileByPath(activity.filePath) + if (file instanceof TFile) { + app.workspace.openLinkText(activity.filePath, '', false) + } + }, [app, activity.filePath]) + const handleRevert = useCallback(async () => { if (!app || isReverting || reverted) return @@ -150,7 +159,17 @@ const EditDiffBlock = memo(function EditDiffBlock({ {isOpen ? : } - {operationLabel} [[{displayName}]] + {operationLabel}{' '} + { + e.stopPropagation() + handleOpenFile() + }} + title={activity.filePath} + > + [[{displayName}]] + {additions > 0 && +{additions}} diff --git a/src/core/backend/BackendProvider.ts b/src/core/backend/BackendProvider.ts index 2f2c3834..7bacc7a9 100644 --- a/src/core/backend/BackendProvider.ts +++ b/src/core/backend/BackendProvider.ts @@ -247,6 +247,9 @@ export class BackendProvider extends BaseLLMProvider { newPath: input.new_path as string, }; + // Track the activity ID so the RPC handler can link snapshots to this activity + this.wsClient.trackActivityStart(name, input, activityId); + enqueueChunk({ id: requestId, object: 'chat.completion.chunk', diff --git a/src/core/backend/EditHistory.ts b/src/core/backend/EditHistory.ts index 31d2260c..f2451f0d 100644 --- a/src/core/backend/EditHistory.ts +++ b/src/core/backend/EditHistory.ts @@ -85,13 +85,20 @@ export class EditHistory { try { const file = this.app.vault.getAbstractFileByPath(path); - if (file && 'stat' in file) { + if (!file || !('stat' in file)) { + return false; + } + + if (snapshot.content === '') { + // Empty snapshot means the file didn't exist before — delete it to revert + await this.app.vault.trash(file as any, true); + } else { await this.app.vault.modify(file as any, snapshot.content); - // Remove this and all newer snapshots after revert - this.snapshots.set(path, snapshots.slice(0, snapshotIndex)); - return true; } - return false; + + // Remove this and all newer snapshots after revert + this.snapshots.set(path, snapshots.slice(0, snapshotIndex)); + return true; } catch (error) { console.error('[EditHistory] Failed to revert:', error); return false; diff --git a/src/core/backend/WebSocketClient.ts b/src/core/backend/WebSocketClient.ts index 5b33f18a..3216f280 100644 --- a/src/core/backend/WebSocketClient.ts +++ b/src/core/backend/WebSocketClient.ts @@ -65,6 +65,11 @@ export class WebSocketClient { >(); private messageQueue: ClientMessage[] = []; private isConnecting = false; + private pendingActivityIds: { + toolName: string; + filePath: string; + activityId: string; + }[] = []; /** * Connect to the backend WebSocket server @@ -236,6 +241,41 @@ export class WebSocketClient { } } + /** + * Track an activity ID from a tool_start event. + * This is consumed by the RPC handler to link edit snapshots to the correct activity. + */ + trackActivityStart( + toolName: string, + input: Record, + activityId: string + ): void { + const filePath = + (input.path as string) || (input.old_path as string) || ''; + this.pendingActivityIds.push({ toolName, filePath, activityId }); + } + + /** + * Consume the activity ID for a given RPC method and params. + * Returns the matching activity ID if found, otherwise undefined. + */ + consumeActivityId( + method: string, + params: Record + ): string | undefined { + const filePath = + (params.path as string) || (params.old_path as string) || ''; + const idx = this.pendingActivityIds.findIndex( + (a) => a.toolName === method && a.filePath === filePath + ); + if (idx !== -1) { + const { activityId } = this.pendingActivityIds[idx]; + this.pendingActivityIds.splice(idx, 1); + return activityId; + } + return undefined; + } + /** * Register an event handler */ @@ -391,7 +431,7 @@ export class WebSocketClient { if (this.isConnected) { this.send({ type: 'ping' }); } - }, 30000); // Ping every 30 seconds + }, 25000); // Ping every 25 seconds (server timeout is 90s) } private stopPingInterval(): void { diff --git a/src/main.ts b/src/main.ts index 9893e6b7..d3581b7c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,11 +51,13 @@ export default class SmartComposerPlugin extends Plugin { const rpcMsg = msg as RpcRequestMessage try { - // Pass RPC id as activityId for edit history tracking + // Look up the activity ID tracked by BackendProvider's onToolStart. + // This links the edit snapshot to the correct ActivityEvent for Undo. + const activityId = webSocketClient.consumeActivityId(rpcMsg.method, rpcMsg.params) || rpcMsg.id const result = await this.vaultRpcHandler!.handleRpc( rpcMsg.method, rpcMsg.params, - rpcMsg.id, // activityId for revert functionality + activityId, ) webSocketClient.sendRpcResponse(rpcMsg.id, result) From 1ea8cb14279a9a656ef17a89def765b5612ecdc6 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Thu, 12 Feb 2026 00:37:46 -0500 Subject: [PATCH 26/40] Add external resource dir setting, fix activity dedup, remove mobile MCP restriction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add externalResourceDir plugin setting for vault-relative PDF path - Fix duplicate activity items by deduplicating editActivities by filePath+type - Map search_cookbooks/list_cookbook_sources/web_search to proper activity types - Remove blanket mobile MCP disable; let transports fail gracefully - Update backend system prompt with source filter and citation format guidance - Settings migration 12→13 Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 8 ++++-- .../AssistantToolMessageGroupItem.tsx | 25 +++++++++++++------ .../settings/sections/ChatSection.tsx | 15 +++++++++++ src/core/mcp/mcpManager.ts | 16 ++++++++---- src/settings/schema/migrations/12_to_13.ts | 16 ++++++++++++ src/settings/schema/migrations/index.ts | 8 +++++- src/settings/schema/setting.types.ts | 3 +++ 7 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 src/settings/schema/migrations/12_to_13.ts diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 55e295d1..04dd64ff 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -43,14 +43,18 @@ const BASE_SYSTEM_PROMPT = `You are an Obsidian note-editing assistant. You help When the user asks about cooking techniques, recipes, ingredients, or food science: - Use search_cookbooks to find information in their cookbook collection - ALWAYS include exact citations from the results: source book name, page numbers, and section -- Format citations like: "*The Professional Chef* (CIA), pp. 593-594" +- When the user asks about a specific book, use the \`sources\` parameter to filter: sources="The Professional Chef" +- For multiple specific sources: sources="ChefSteps, Modernist Cuisine" +- Citations from the tool include clickable links — preserve them in your response: + - PDF sources: include the source name and page numbers as given + - Web sources (ChefSteps): include the markdown URL link as given - Include multiple sources when available for a comprehensive answer - Quote key passages directly when they're particularly informative ## Response Style - Be concise but helpful - Explain what changes you're making -- When citing cookbook sources, always include the exact page numbers +- When citing cookbook sources, always include the exact page numbers and preserve any links from the tool results - If uncertain, ask for clarification`; interface Skill { diff --git a/src/components/chat-view/AssistantToolMessageGroupItem.tsx b/src/components/chat-view/AssistantToolMessageGroupItem.tsx index 8c19248d..c8b7d528 100644 --- a/src/components/chat-view/AssistantToolMessageGroupItem.tsx +++ b/src/components/chat-view/AssistantToolMessageGroupItem.tsx @@ -163,19 +163,20 @@ export default function AssistantToolMessageGroupItem({ if (message.role === 'tool' && message.toolCalls) { for (const toolCall of message.toolCalls) { const toolName = toolCall.request.name - if (!toolName.startsWith('backend__') && !toolName.startsWith('vault_')) { - continue - } const activityId = `tool-${toolCall.request.id}` if (seenIds.has(activityId)) continue seenIds.add(activityId) const cleanName = toolName.replace('backend__', '') - let activityType: ActivityEvent['type'] = 'tool_call' - if (cleanName.startsWith('vault_')) { - activityType = cleanName as ActivityEvent['type'] + const toolTypeMapping: Record = { + search_cookbooks: 'search_cookbooks', + list_cookbook_sources: 'list_cookbook_sources', + web_search: 'web_search', } + let activityType: ActivityEvent['type'] = + toolTypeMapping[cleanName] || + (cleanName.startsWith('vault_') ? cleanName as ActivityEvent['type'] : 'tool_call') let toolInput: Record = {} try { @@ -219,7 +220,17 @@ export default function AssistantToolMessageGroupItem({ } } - return { allActivities: activities, editActivities: edits } + // Deduplicate edit activities by filePath + type, preferring entries with diff data + const dedupedEdits = new Map() + for (const edit of edits) { + const key = `${edit.filePath || edit.id}:${edit.type}` + const existing = dedupedEdits.get(key) + if (!existing || (edit.diff && !existing.diff)) { + dedupedEdits.set(key, edit) + } + } + + return { allActivities: activities, editActivities: Array.from(dedupedEdits.values()) } }, [messages]) return ( diff --git a/src/components/settings/sections/ChatSection.tsx b/src/components/settings/sections/ChatSection.tsx index ee64f0ce..a167492e 100644 --- a/src/components/settings/sections/ChatSection.tsx +++ b/src/components/settings/sections/ChatSection.tsx @@ -137,6 +137,21 @@ export function ChatSection() { }} /> + + + { + await setSettings({ + ...settings, + externalResourceDir: value, + }) + }} + /> +
      ) } diff --git a/src/core/mcp/mcpManager.ts b/src/core/mcp/mcpManager.ts index edb9e153..6f59a1ba 100644 --- a/src/core/mcp/mcpManager.ts +++ b/src/core/mcp/mcpManager.ts @@ -1,5 +1,4 @@ import isEqual from 'lodash.isequal' -import { Platform } from 'obsidian' import { SmartComposerSettings } from '../../settings/schema/setting.types' import { @@ -24,7 +23,10 @@ import { export class McpManager { static readonly TOOL_NAME_DELIMITER = '__' // Delimiter for tool name construction (serverName__toolName) - public readonly disabled = !Platform.isDesktop // MCP should be disabled on mobile since it doesn't support node.js + // Local MCP servers using stdio transport may not work on mobile (no Node.js subprocess). + // Remote MCP via backend provider works on all platforms. + // Instead of blanket-disabling, let individual transports fail gracefully. + public readonly disabled = false private settings: SmartComposerSettings private unsubscribeFromSettings: () => void @@ -57,9 +59,13 @@ export class McpManager { return } - // Get default environment variables - const { shellEnvSync } = await import('shell-env') - this.defaultEnv = shellEnvSync() + // Get default environment variables (may fail on mobile - non-fatal) + try { + const { shellEnvSync } = await import('shell-env') + this.defaultEnv = shellEnvSync() + } catch { + this.defaultEnv = {} + } // Create MCP servers const servers = await Promise.all( diff --git a/src/settings/schema/migrations/12_to_13.ts b/src/settings/schema/migrations/12_to_13.ts new file mode 100644 index 00000000..3c39475d --- /dev/null +++ b/src/settings/schema/migrations/12_to_13.ts @@ -0,0 +1,16 @@ +import { SettingMigration } from '../setting.types' + +/** + * Migration from version 12 to version 13 + * - Add externalResourceDir setting (vault-relative path to PDFs/cookbooks) + */ +export const migrateFrom12To13: SettingMigration['migrate'] = (data) => { + const newData = { ...data } + newData.version = 13 + + if (!('externalResourceDir' in newData)) { + newData.externalResourceDir = '' + } + + return newData +} diff --git a/src/settings/schema/migrations/index.ts b/src/settings/schema/migrations/index.ts index f3842551..a61e1d07 100644 --- a/src/settings/schema/migrations/index.ts +++ b/src/settings/schema/migrations/index.ts @@ -3,6 +3,7 @@ import { SettingMigration } from '../setting.types' import { migrateFrom0To1 } from './0_to_1' import { migrateFrom10To11 } from './10_to_11' import { migrateFrom11To12 } from './11_to_12' +import { migrateFrom12To13 } from './12_to_13' import { migrateFrom1To2 } from './1_to_2' import { migrateFrom2To3 } from './2_to_3' import { migrateFrom3To4 } from './3_to_4' @@ -13,7 +14,7 @@ import { migrateFrom7To8 } from './7_to_8' import { migrateFrom8To9 } from './8_to_9' import { migrateFrom9To10 } from './9_to_10' -export const SETTINGS_SCHEMA_VERSION = 12 +export const SETTINGS_SCHEMA_VERSION = 13 export const SETTING_MIGRATIONS: SettingMigration[] = [ { @@ -76,4 +77,9 @@ export const SETTING_MIGRATIONS: SettingMigration[] = [ toVersion: 12, migrate: migrateFrom11To12, }, + { + fromVersion: 12, + toVersion: 13, + migrate: migrateFrom12To13, + }, ] diff --git a/src/settings/schema/setting.types.ts b/src/settings/schema/setting.types.ts index 5282441b..bf37fccc 100644 --- a/src/settings/schema/setting.types.ts +++ b/src/settings/schema/setting.types.ts @@ -75,6 +75,9 @@ export const smartComposerSettingsSchema = z.object({ servers: [], }), + // External resource directory (vault-relative path to PDFs/cookbooks) + externalResourceDir: z.string().catch(''), + // Chat options chatOptions: z .object({ From 28ca276f03b9a54990173cab22a38e4241328c6d Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:21:35 -0500 Subject: [PATCH 27/40] Update system prompt to enforce PDF wikilink citation format LLM was stripping [[cookbooks/file.pdf#page=N]] wikilinks and reformatting as plain text. Updated Cookbook Research Tools section to mark wikilink preservation as CRITICAL with explicit examples and instructions. Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 04dd64ff..954b6c61 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -45,9 +45,10 @@ When the user asks about cooking techniques, recipes, ingredients, or food scien - ALWAYS include exact citations from the results: source book name, page numbers, and section - When the user asks about a specific book, use the \`sources\` parameter to filter: sources="The Professional Chef" - For multiple specific sources: sources="ChefSteps, Modernist Cuisine" -- Citations from the tool include clickable links — preserve them in your response: - - PDF sources: include the source name and page numbers as given - - Web sources (ChefSteps): include the markdown URL link as given +- **CRITICAL: Citation format rules — copy these EXACTLY as they appear in tool results:** + - PDF citations start with \`[[cookbooks/filename.pdf#page=N]]\` — this is an Obsidian wikilink that opens the PDF to the exact page. You MUST include this exact text in your response for every PDF citation. Example: \`[[cookbooks/CIA professional chef.pdf#page=304]] CIA professional chef, pp. 285-286\` + - ChefSteps citations use markdown links like \`[ChefSteps: title](https://...)\` — preserve these as-is + - Do NOT rewrite, summarize, or strip the \`[[...]]\` wikilinks — they are clickable deep links - Include multiple sources when available for a comprehensive answer - Quote key passages directly when they're particularly informative From bbcea40288b2ef1af0d12c9b5ca9963242a39a00 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:50:12 -0500 Subject: [PATCH 28/40] Fix stop generation button: abort-aware promise + disconnect cleanup BackendProvider: Race message promise against abort signal so clicking Stop immediately wakes the generator and sends cancel to backend. WebSocketClient: Notify active stream handlers on disconnect so generators don't hang forever when the connection drops. server.ts: Send safety-net complete message in finally block so the client never gets stuck waiting for a terminal event. Co-Authored-By: Claude Opus 4.6 --- backend/src/server.ts | 8 +++++ src/core/backend/BackendProvider.ts | 49 +++++++++++++++++++++++------ src/core/backend/WebSocketClient.ts | 9 ++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index a1aa73ab..f254d750 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -180,6 +180,14 @@ class ConnectionHandler { message: err instanceof Error ? err.message : 'Unknown error', }); } finally { + // Safety net: always send a complete event so the client never hangs. + // If the agent already sent one, the client handler was already deleted + // and this is a harmless no-op. + this.send({ + type: 'complete', + requestId: msg.id, + result: '', + }); this.activeRequests.delete(msg.id); } } diff --git a/src/core/backend/BackendProvider.ts b/src/core/backend/BackendProvider.ts index 7bacc7a9..1fb6c3fa 100644 --- a/src/core/backend/BackendProvider.ts +++ b/src/core/backend/BackendProvider.ts @@ -415,22 +415,53 @@ export class BackendProvider extends BaseLLMProvider { // Yield chunks as they arrive while (!isComplete || messageQueue.length > 0) { + // Check for abort before waiting + if (options?.signal?.aborted) { + this.wsClient.cancelRequest(requestId); + break; + } + if (messageQueue.length > 0) { yield messageQueue.shift()!; } else { - // Wait for next chunk - const chunk = await new Promise( + // Wait for next chunk, racing against abort signal + const chunk = await new Promise( (resolve) => { - resolveNext = resolve; + let resolved = false; + resolveNext = (msg) => { + if (!resolved) { + resolved = true; + resolve(msg); + } + }; + + // Race: also resolve with null when abort signal fires + if (options?.signal) { + if (options.signal.aborted) { + resolved = true; + resolve(null); + return; + } + options.signal.addEventListener( + 'abort', + () => { + if (!resolved) { + resolved = true; + resolve(null); + } + }, + { once: true } + ); + } } ); - yield chunk; - } - // Check for abort - if (options?.signal?.aborted) { - this.wsClient.cancelRequest(requestId); - break; + // Null means aborted + if (chunk === null) { + this.wsClient.cancelRequest(requestId); + break; + } + yield chunk; } } diff --git a/src/core/backend/WebSocketClient.ts b/src/core/backend/WebSocketClient.ts index 3216f280..f8f003d9 100644 --- a/src/core/backend/WebSocketClient.ts +++ b/src/core/backend/WebSocketClient.ts @@ -371,6 +371,15 @@ export class WebSocketClient { } this.pendingRpcs.clear(); + // Notify all active stream handlers so generators don't hang + for (const [id, handlers] of this.activeHandlers) { + handlers.onError?.( + 'DISCONNECTED', + 'Connection closed unexpectedly' + ); + } + this.activeHandlers.clear(); + // Attempt reconnection if not a normal closure if (event.code !== 1000 && this.config) { this.attemptReconnect(); From d052277b65059fbf32cf0cf01df11fb0b75db678 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:57:43 -0500 Subject: [PATCH 29/40] Add per-message timeout to agent loop to prevent infinite hangs When an external MCP server dies (e.g. cookbook-rag OOM), the SDK's query() hangs forever waiting for a tool response. This wraps the iterator with a 2-minute timeout that aborts the agent and yields an error if no message arrives. Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 954b6c61..5cd35504 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -155,6 +155,7 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; const MAX_TURNS = 10; +const MESSAGE_TIMEOUT_MS = 120_000; // 2 minutes per SDK message /** * Strip MCP prefix from tool names for cleaner display. @@ -255,8 +256,37 @@ export async function* runAgent( } } + // Helper: wrap an async iterator with a per-message timeout. + // If no message arrives within timeoutMs, the abort controller fires + // and the iterator throws, breaking the hang. + async function* withMessageTimeout( + iter: AsyncIterable, + timeoutMs: number, + abort: AbortController, + ): AsyncGenerator { + const asyncIter = iter[Symbol.asyncIterator](); + while (true) { + const result = await Promise.race([ + asyncIter.next(), + new Promise((_, reject) => { + const timer = setTimeout(() => { + logger.error(`Message timeout after ${timeoutMs / 1000}s — aborting agent`); + abort.abort(); + reject(new Error(`Agent timed out waiting for SDK response (${timeoutMs / 1000}s)`)); + }, timeoutMs); + // Allow GC if iterator completes first + (timer as any).unref?.(); + // Clear timeout if abort fires from another source + abort.signal.addEventListener('abort', () => clearTimeout(timer), { once: true }); + }), + ]); + if (result.done) break; + yield result.value; + } + } + try { - for await (const message of query({ + const queryStream = query({ prompt: singlePrompt(), options: { model: selectedModel, @@ -271,7 +301,9 @@ export async function* runAgent( logger.warn(`CLI stderr: ${data.trimEnd()}`); }, }, - })) { + }); + + for await (const message of withMessageTimeout(queryStream, MESSAGE_TIMEOUT_MS, abortController)) { // Drain tool_end events pushed by vault tool handlers yield* drainToolEvents(); From 78b2319d9d665194ace890a57d56e7fd1caf21f3 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:01:17 -0500 Subject: [PATCH 30/40] Replace per-message timeout with heartbeat-based inactivity detection, stream thinking tokens - Replace 2-min withMessageTimeout (killed legitimate multi-tool turns) with activity-based approach: heartbeat updated by SDK messages, vault tool handlers, and stderr; 15s interval check; 5-min inactivity threshold - Stream thinking_delta tokens in real time (Cursor-like UX) - Emit tool_start from content_block_start stream events (earlier than waiting for full assistant message), with dedup against assistant fallback - Add heartbeat callback to createVaultMcpServer, called by all 9 vault tool handlers on execution Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 100 +++++++++++++++++++++---------------- backend/src/vault-tools.ts | 11 +++- 2 files changed, 68 insertions(+), 43 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 5cd35504..377c6f8c 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -155,7 +155,7 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; const MAX_TURNS = 10; -const MESSAGE_TIMEOUT_MS = 120_000; // 2 minutes per SDK message +const INACTIVITY_TIMEOUT_MS = 300_000; // 5 minutes of no activity = dead /** * Strip MCP prefix from tool names for cleaner display. @@ -183,7 +183,12 @@ export async function* runAgent( // Shared event queue — tool handlers push tool_end events here const eventQueue: AgentEvent[] = []; - const vaultServer = createVaultMcpServer(bridge, eventQueue); + + // Activity tracker — updated by vault tool handlers, SDK iterator messages, and stderr + const activity = { lastTs: Date.now() }; + const heartbeat = () => { activity.lastTs = Date.now(); }; + + const vaultServer = createVaultMcpServer(bridge, eventQueue, heartbeat); // AbortController for the SDK (forward external signal) const abortController = new AbortController(); @@ -256,34 +261,18 @@ export async function* runAgent( } } - // Helper: wrap an async iterator with a per-message timeout. - // If no message arrives within timeoutMs, the abort controller fires - // and the iterator throws, breaking the hang. - async function* withMessageTimeout( - iter: AsyncIterable, - timeoutMs: number, - abort: AbortController, - ): AsyncGenerator { - const asyncIter = iter[Symbol.asyncIterator](); - while (true) { - const result = await Promise.race([ - asyncIter.next(), - new Promise((_, reject) => { - const timer = setTimeout(() => { - logger.error(`Message timeout after ${timeoutMs / 1000}s — aborting agent`); - abort.abort(); - reject(new Error(`Agent timed out waiting for SDK response (${timeoutMs / 1000}s)`)); - }, timeoutMs); - // Allow GC if iterator completes first - (timer as any).unref?.(); - // Clear timeout if abort fires from another source - abort.signal.addEventListener('abort', () => clearTimeout(timer), { once: true }); - }), - ]); - if (result.done) break; - yield result.value; + // Periodic inactivity check — aborts agent if no activity for INACTIVITY_TIMEOUT_MS + const activityCheck = setInterval(() => { + const elapsed = Date.now() - activity.lastTs; + if (elapsed > INACTIVITY_TIMEOUT_MS) { + logger.error(`No activity for ${Math.round(elapsed / 1000)}s — aborting agent`); + abortController.abort(); } - } + }, 15_000); + (activityCheck as any).unref?.(); + + // Track tool_use IDs already started via stream events (avoid duplicate tool_start) + const streamStartedToolIds = new Set(); try { const queryStream = query({ @@ -298,23 +287,44 @@ export async function* runAgent( permissionMode: 'bypassPermissions' as const, includePartialMessages: true, stderr: (data: string) => { + heartbeat(); // stderr output = activity logger.warn(`CLI stderr: ${data.trimEnd()}`); }, }, }); - for await (const message of withMessageTimeout(queryStream, MESSAGE_TIMEOUT_MS, abortController)) { + for await (const message of queryStream) { + heartbeat(); // SDK yielded a message = alive + // Drain tool_end events pushed by vault tool handlers yield* drainToolEvents(); switch (message.type) { case 'stream_event': { - // Text streaming means all pending tools have completed - yield* closePendingTools(); - const event = message.event; - if (event.type === 'content_block_delta' && 'text' in event.delta) { - yield { type: 'text_delta', text: (event.delta as any).text }; + + // Early tool_start from content_block_start (before full assistant message) + if (event.type === 'content_block_start') { + const block = (event as any).content_block; + if (block?.type === 'tool_use') { + const name = cleanToolName(block.name); + streamStartedToolIds.add(block.id); + pendingTools.push(name); + yield { type: 'tool_start', name, input: block.input || {} }; + } else { + // New text or thinking block = previous turn's tools are done + yield* closePendingTools(); + } + } + + // Stream text and thinking deltas in real time + if (event.type === 'content_block_delta') { + const delta = (event as any).delta; + if (delta?.type === 'text_delta') { + yield { type: 'text_delta', text: delta.text }; + } else if (delta?.type === 'thinking_delta') { + yield { type: 'thinking', text: delta.thinking }; + } } break; } @@ -323,16 +333,20 @@ export async function* runAgent( // Close any pending tools from the previous turn yield* closePendingTools(); - // Emit tool_start for each tool_use block in the assistant message + // Emit tool_start for tool_use blocks NOT already started via stream events for (const block of message.message.content) { if (block.type === 'tool_use') { const name = cleanToolName(block.name); - pendingTools.push(name); - yield { - type: 'tool_start', - name, - input: block.input as Record, - }; + if (!streamStartedToolIds.has(block.id)) { + // Fallback: emit tool_start if stream event was missed + pendingTools.push(name); + yield { + type: 'tool_start', + name, + input: block.input as Record, + }; + } + streamStartedToolIds.delete(block.id); } } break; @@ -370,5 +384,7 @@ export async function* runAgent( code: 'AGENT_ERROR', message: errMsg, }; + } finally { + clearInterval(activityCheck); } } diff --git a/backend/src/vault-tools.ts b/backend/src/vault-tools.ts index da34fb7d..aa3dc495 100644 --- a/backend/src/vault-tools.ts +++ b/backend/src/vault-tools.ts @@ -122,7 +122,7 @@ export async function executeVaultTool( * Create an SDK MCP server with all vault tools bound to a VaultBridge. * Tool handlers push tool_end events to the shared queue for the agent generator. */ -export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent[]) { +export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent[], heartbeat?: () => void) { return createSdkMcpServer({ name: 'vault-tools', version: '1.0.0', @@ -134,6 +134,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent path: z.string().describe('Path relative to vault root, e.g. "folder/note.md" or "note.md"'), }, async (args) => { + heartbeat?.(); const content = await bridge.read(args.path); eventQueue.push({ type: 'tool_end', name: 'vault_read', result: content }); return { content: [{ type: 'text' as const, text: content }] }; @@ -148,6 +149,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent content: z.string().describe('Full markdown content to write'), }, async (args) => { + heartbeat?.(); await bridge.write(args.path, args.content); const result = `Successfully wrote ${args.content.length} characters to ${args.path}`; eventQueue.push({ type: 'tool_end', name: 'vault_write', result }); @@ -164,6 +166,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent new_string: z.string().describe('Text to replace it with'), }, async (args) => { + heartbeat?.(); await bridge.edit(args.path, args.old_string, args.new_string); const result = `Successfully edited ${args.path}`; eventQueue.push({ type: 'tool_end', name: 'vault_edit', result }); @@ -179,6 +182,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent limit: z.number().optional().describe('Maximum results to return (default: 20)'), }, async (args) => { + heartbeat?.(); const limit = args.limit ?? 20; const results = await bridge.search(args.query, limit); let result: string; @@ -205,6 +209,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent limit: z.number().optional().describe('Maximum results to return (default: 50)'), }, async (args) => { + heartbeat?.(); const folder = args.folder || ''; const filePattern = args.file_pattern || '*.md'; const limit = args.limit ?? 50; @@ -230,6 +235,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent pattern: z.string().describe('Glob pattern, e.g. "**/*.md", "daily/*.md", "projects/**/*"'), }, async (args) => { + heartbeat?.(); const files = await bridge.glob(args.pattern); let result: string; if (files.length === 0) { @@ -250,6 +256,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent folder: z.string().optional().describe('Folder path relative to vault root, empty for root'), }, async (args) => { + heartbeat?.(); const folder = args.folder || ''; const items = await bridge.list(folder); let result: string; @@ -274,6 +281,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent new_path: z.string().describe('New path for the note'), }, async (args) => { + heartbeat?.(); await bridge.rename(args.old_path, args.new_path); const result = `Renamed ${args.old_path} → ${args.new_path}`; eventQueue.push({ type: 'tool_end', name: 'vault_rename', result }); @@ -288,6 +296,7 @@ export function createVaultMcpServer(bridge: VaultBridge, eventQueue: AgentEvent path: z.string().describe('Path of the note to delete'), }, async (args) => { + heartbeat?.(); await bridge.delete(args.path); const result = `Deleted ${args.path}`; eventQueue.push({ type: 'tool_end', name: 'vault_delete', result }); From 44fd681333951f2d3865aab9dfdf06889d2a9844 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:07:00 -0500 Subject: [PATCH 31/40] Fix tool_start to include full input, clean up pending tools on cancel - Revert early tool_start from content_block_start (sent empty input {}, caused "Cookbook search: ''" in activity UI). Tool_start now emits from the assistant message which has the complete input parameters. - Keep thinking_delta streaming from stream events (real-time thinking) - Add pending tool cleanup in catch block so cancelled requests don't leave stuck "running" activities in the UI - Remove unused streamStartedToolIds tracking Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 46 +++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 377c6f8c..98e1800b 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -271,9 +271,6 @@ export async function* runAgent( }, 15_000); (activityCheck as any).unref?.(); - // Track tool_use IDs already started via stream events (avoid duplicate tool_start) - const streamStartedToolIds = new Set(); - try { const queryStream = query({ prompt: singlePrompt(), @@ -301,25 +298,14 @@ export async function* runAgent( switch (message.type) { case 'stream_event': { - const event = message.event; - - // Early tool_start from content_block_start (before full assistant message) - if (event.type === 'content_block_start') { - const block = (event as any).content_block; - if (block?.type === 'tool_use') { - const name = cleanToolName(block.name); - streamStartedToolIds.add(block.id); - pendingTools.push(name); - yield { type: 'tool_start', name, input: block.input || {} }; - } else { - // New text or thinking block = previous turn's tools are done - yield* closePendingTools(); - } + // New content block starting = previous turn's tools are done + if (message.event.type === 'content_block_start') { + yield* closePendingTools(); } // Stream text and thinking deltas in real time - if (event.type === 'content_block_delta') { - const delta = (event as any).delta; + if (message.event.type === 'content_block_delta') { + const delta = (message.event as any).delta; if (delta?.type === 'text_delta') { yield { type: 'text_delta', text: delta.text }; } else if (delta?.type === 'thinking_delta') { @@ -333,20 +319,16 @@ export async function* runAgent( // Close any pending tools from the previous turn yield* closePendingTools(); - // Emit tool_start for tool_use blocks NOT already started via stream events + // Emit tool_start for each tool_use block (with full input) for (const block of message.message.content) { if (block.type === 'tool_use') { const name = cleanToolName(block.name); - if (!streamStartedToolIds.has(block.id)) { - // Fallback: emit tool_start if stream event was missed - pendingTools.push(name); - yield { - type: 'tool_start', - name, - input: block.input as Record, - }; - } - streamStartedToolIds.delete(block.id); + pendingTools.push(name); + yield { + type: 'tool_start', + name, + input: block.input as Record, + }; } } break; @@ -375,6 +357,10 @@ export async function* runAgent( } } } catch (err) { + // Close any pending tools so the UI doesn't show stuck "running" states + yield* drainToolEvents(); + yield* closePendingTools(); + const errMsg = err instanceof Error ? err.message : 'Unknown error'; const errStack = err instanceof Error ? err.stack : ''; logger.error('Agent error:', errMsg); From 7266bd23f94024cb5f18d2038e90770a376273a7 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:56:41 -0500 Subject: [PATCH 32/40] Fix activity timer, enable thinking, increase timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 2: Track completeSent flag in server.ts to prevent safety-net complete from racing with tool_end events. Delay handler deletion in WebSocketClient by 1s after complete. - Fix 3: Enable extended thinking (budgetTokens: 10000) in SDK query. Add diagnostic logging for thinking block start and deltas. - Fix 4: Increase maxTurns 10→25, inactivity timeout 5→10min, RPC timeout 30→60s. Add result logging for timeout diagnosis. - Add interspersed layout spec doc for future implementation. Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 12 +- backend/src/server.ts | 22 +- docs/interspersed-layout-spec.md | 560 ++++++++++++++++++++++++++++ src/core/backend/WebSocketClient.ts | 4 +- 4 files changed, 586 insertions(+), 12 deletions(-) create mode 100644 docs/interspersed-layout-spec.md diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 98e1800b..2256faa4 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -154,8 +154,8 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { } const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; -const MAX_TURNS = 10; -const INACTIVITY_TIMEOUT_MS = 300_000; // 5 minutes of no activity = dead +const MAX_TURNS = 25; // Complex cookbook queries need many tool calls +const INACTIVITY_TIMEOUT_MS = 600_000; // 10 minutes of no activity = dead /** * Strip MCP prefix from tool names for cleaner display. @@ -283,6 +283,7 @@ export async function* runAgent( abortController, permissionMode: 'bypassPermissions' as const, includePartialMessages: true, + thinking: { type: 'enabled', budgetTokens: 10000 }, stderr: (data: string) => { heartbeat(); // stderr output = activity logger.warn(`CLI stderr: ${data.trimEnd()}`); @@ -301,6 +302,10 @@ export async function* runAgent( // New content block starting = previous turn's tools are done if (message.event.type === 'content_block_start') { yield* closePendingTools(); + const block = (message.event as any).content_block; + if (block?.type === 'thinking') { + logger.info('[Thinking] Block started'); + } } // Stream text and thinking deltas in real time @@ -309,6 +314,7 @@ export async function* runAgent( if (delta?.type === 'text_delta') { yield { type: 'text_delta', text: delta.text }; } else if (delta?.type === 'thinking_delta') { + logger.info(`[Thinking] delta: ${(delta.thinking || '').substring(0, 80)}...`); yield { type: 'thinking', text: delta.thinking }; } } @@ -340,9 +346,11 @@ export async function* runAgent( yield* closePendingTools(); if (message.subtype === 'success') { + logger.info('Agent completed successfully'); yield { type: 'complete', result: message.result || '' }; } else { const errors = 'errors' in message ? (message as any).errors : []; + logger.error(`Agent stopped: ${message.subtype}`, errors); yield { type: 'error', code: message.subtype, diff --git a/backend/src/server.ts b/backend/src/server.ts index f254d750..1daeb0f0 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -98,6 +98,7 @@ class ConnectionHandler { private async handlePrompt(msg: PromptMessage) { const abortController = new AbortController(); this.activeRequests.set(msg.id, abortController); + let completeSent = false; logger.info(`Processing prompt request ${msg.id}`); @@ -153,6 +154,7 @@ class ConnectionHandler { }); break; case 'complete': + completeSent = true; this.send({ type: 'complete', requestId: msg.id, @@ -180,14 +182,16 @@ class ConnectionHandler { message: err instanceof Error ? err.message : 'Unknown error', }); } finally { - // Safety net: always send a complete event so the client never hangs. - // If the agent already sent one, the client handler was already deleted - // and this is a harmless no-op. - this.send({ - type: 'complete', - requestId: msg.id, - result: '', - }); + // Safety net: only send complete if the agent didn't already send one. + // Previously this always fired, which could race with tool_end events + // and cause the client to delete handlers before processing them. + if (!completeSent) { + this.send({ + type: 'complete', + requestId: msg.id, + result: '', + }); + } this.activeRequests.delete(msg.id); } } @@ -225,7 +229,7 @@ class ConnectionHandler { params: Record ): Promise { const id = randomUUID(); - const RPC_TIMEOUT = 30000; // 30 seconds + const RPC_TIMEOUT = 60000; // 60 seconds return new Promise((resolve, reject) => { const timeout = setTimeout(() => { diff --git a/docs/interspersed-layout-spec.md b/docs/interspersed-layout-spec.md new file mode 100644 index 00000000..18d7ffb9 --- /dev/null +++ b/docs/interspersed-layout-spec.md @@ -0,0 +1,560 @@ +# Interspersed Activity Layout Spec + +## Goal + +Replace the current grouped activity layout (all activities at top, then text at bottom) with a chronologically interspersed layout like Cursor — text and activities appear in the order they happen during the agent's execution. + +**Before** (current): +``` +[Activity Accordion: 5 searches, 2 files] + - search_cookbooks "braising" + - vault_read "notes/braising.md" + - search_cookbooks "Peterson sauces" + - vault_read "notes/sauces.md" + - vault_write "guides/braising-guide.md" +[Edit Diff: braising-guide.md +45 lines] +Here's what I found about braising techniques... +[full response text] +``` + +**After** (interspersed): +``` +[Explored 2 searches] + - search_cookbooks "braising" + - search_cookbooks "Peterson sauces" + +Here's what I found about braising techniques. The CIA Professional Chef +describes braising as... + +[Read 2 files] + - vault_read "notes/braising.md" + - vault_read "notes/sauces.md" + +Comparing these with Peterson's approach... + +[Created braising-guide.md +45 lines] + [diff block] + +I've created a comprehensive braising guide combining both sources. +``` + +## Current Architecture + +### Data Flow + +``` +Backend (agent.ts) + → yields AgentEvent (text_delta | tool_start | tool_end | thinking | complete) + → server.ts relays via WebSocket + → WebSocketClient.ts receives, calls StreamingHandlers + → BackendProvider.ts converts to LLMResponseStreaming chunks with delta.activity + → responseGenerator.ts accumulates into ChatAssistantMessage + → AssistantToolMessageGroupItem.tsx renders +``` + +### Current Data Model + +```typescript +// types/chat.ts +type ChatAssistantMessage = { + role: 'assistant' + content: string // Flat text string, no timing info + activities?: ActivityEvent[] // Array of all activities, chronologically ordered + reasoning?: string // Extended thinking content + annotations?: Annotation[] + // ... +} +``` + +### Current Rendering (AssistantToolMessageGroupItem.tsx) + +``` +1. // ALL activities grouped +2. // ALL edits grouped +3. {messages.map(m => )} // ALL text at bottom +``` + +### Key Problem + +`content` is a flat `string` with no timing information. Activities have `startTime`/`endTime`, but text deltas don't. We can't tell where activities should be inserted relative to text because we don't know when each chunk of text was emitted. + +## Proposed Data Model + +### New Types (types/chat.ts) + +```typescript +/** + * A block of content in the chronological stream. + * Text blocks coalesce adjacent text deltas. + * Activity groups coalesce adjacent tool calls (e.g., 3 reads in a row). + */ +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'activity_group'; activityIds: string[] } + +export type ChatAssistantMessage = { + role: 'assistant' + content: string // KEPT: computed from text blocks for backward compat + contentBlocks?: ContentBlock[] // NEW: chronological sequence + activities?: ActivityEvent[] // KEPT: canonical activity storage (lookup by ID) + reasoning?: string // KEPT for backward compat + annotations?: Annotation[] + // ... rest unchanged +} +``` + +### Design Principles + +1. **Adjacent text coalesces**: Multiple text deltas in a row merge into one `text` block +2. **Adjacent activities group**: Multiple tool calls in a row merge into one `activity_group` block (rendered as a mini-accordion) +3. **Boundaries create new blocks**: When a tool_start arrives after text, the current text block closes and an activity_group block opens. When text arrives after tool_end, the activity_group closes and a new text block opens. +4. **Backward compatible**: `contentBlocks` is optional. Old conversations without it use legacy rendering. +5. **`content` stays in sync**: Flat `content` string is always computed from text blocks for backward compat and search. + +## Implementation + +### 1. BackendProvider.ts Changes + +Track the "current block type" during streaming to know when to start a new block vs append to the current one. + +```typescript +// State added to createStreamGenerator(): +let currentBlockType: 'text' | 'activity_group' | null = null; +let currentTextBlock: string = ''; +let currentActivityGroup: string[] = []; + +// Modified onTextDelta: +onTextDelta: (text: string) => { + if (currentBlockType !== 'text') { + // Close any pending activity group + if (currentBlockType === 'activity_group' && currentActivityGroup.length > 0) { + enqueueContentBlock({ type: 'activity_group', activityIds: [...currentActivityGroup] }); + currentActivityGroup = []; + } + currentBlockType = 'text'; + currentTextBlock = ''; + } + currentTextBlock += text; + // Don't emit text block yet — wait for boundary (tool_start or complete) + // But DO emit the text delta for streaming display + enqueueChunk({ delta: { content: text } }); +} + +// Modified onToolStart: +onToolStart: (name: string, input: Record) => { + if (currentBlockType === 'text' && currentTextBlock) { + enqueueContentBlock({ type: 'text', text: currentTextBlock }); + currentTextBlock = ''; + } + currentBlockType = 'activity_group'; + const activityId = `activity-${requestId}-${index}`; + currentActivityGroup.push(activityId); + // ... existing activity event emission +} + +// Modified onComplete: +onComplete: (result: string) => { + // Flush any pending blocks + if (currentBlockType === 'text' && currentTextBlock) { + enqueueContentBlock({ type: 'text', text: currentTextBlock }); + } else if (currentBlockType === 'activity_group' && currentActivityGroup.length > 0) { + enqueueContentBlock({ type: 'activity_group', activityIds: [...currentActivityGroup] }); + } + // ... existing complete logic +} +``` + +The `enqueueContentBlock` helper emits a chunk with `delta.contentBlock`: +```typescript +const enqueueContentBlock = (block: ContentBlock) => { + enqueueChunk({ + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [{ delta: { contentBlock: block }, finish_reason: null }], + }); +}; +``` + +### 2. responseGenerator.ts Changes + +Add `mergeContentBlocks` method alongside existing `mergeActivities`: + +```typescript +private mergeContentBlocks( + prevBlocks?: ContentBlock[], + newBlock?: ContentBlock, +): ContentBlock[] | undefined { + if (!newBlock) return prevBlocks; + if (!prevBlocks) return [newBlock]; + + const last = prevBlocks[prevBlocks.length - 1]; + + // Coalesce adjacent text blocks + if (newBlock.type === 'text' && last?.type === 'text') { + return [ + ...prevBlocks.slice(0, -1), + { type: 'text', text: last.text + newBlock.text }, + ]; + } + + // Coalesce adjacent activity groups + if (newBlock.type === 'activity_group' && last?.type === 'activity_group') { + return [ + ...prevBlocks.slice(0, -1), + { type: 'activity_group', activityIds: [...last.activityIds, ...newBlock.activityIds] }, + ]; + } + + // Different type = new block + return [...prevBlocks, newBlock]; +} +``` + +In the chunk processing section (around line 283-298), add: +```typescript +contentBlocks: chunk.delta.contentBlock + ? this.mergeContentBlocks(message.contentBlocks, chunk.delta.contentBlock) + : message.contentBlocks, +``` + +Also compute flat `content` from text blocks: +```typescript +// After merging contentBlocks, recompute flat content: +content: message.contentBlocks + ? message.contentBlocks + .filter((b): b is { type: 'text'; text: string } => b.type === 'text') + .map(b => b.text) + .join('') + : message.content + (content || ''), +``` + +### 3. LLM Response Types (types/llm/response.ts) + +Add `contentBlock` to the delta type: +```typescript +interface StreamDelta { + content?: string; + activity?: ActivityEvent; + contentBlock?: ContentBlock; // NEW + reasoning?: string; + tool_calls?: ToolCallDelta[]; + // ... +} +``` + +### 4. New Component: InterspersedContent.tsx + +```typescript +// ~/claudsidian/src/components/chat-view/InterspersedContent.tsx + +import { useMemo } from 'react'; +import type { ActivityEvent, ContentBlock } from '../../types/chat'; +import type { ChatMessage } from '../../types/chat'; +import ActivityAccordion from './ActivityAccordion'; +import AssistantMessageContent from './AssistantMessageContent'; +import EditDiffBlock from './EditDiffBlock'; + +interface InterspersedContentProps { + contentBlocks: ContentBlock[]; + activities: ActivityEvent[]; + isStreaming: boolean; + contextMessages: ChatMessage[]; + isApplying: boolean; + onApply: (blockToApply: string, chatMessages: ChatMessage[]) => void; +} + +export default function InterspersedContent({ + contentBlocks, + activities, + isStreaming, + contextMessages, + isApplying, + onApply, +}: InterspersedContentProps) { + // Build activity lookup map + const activityMap = useMemo( + () => new Map(activities.map((a) => [a.id, a])), + [activities], + ); + + return ( +
      + {contentBlocks.map((block, idx) => { + const isLastBlock = idx === contentBlocks.length - 1; + + switch (block.type) { + case 'text': + return ( + + ); + + case 'activity_group': { + const groupActivities = block.activityIds + .map((id) => activityMap.get(id)) + .filter((a): a is ActivityEvent => a !== undefined); + + if (groupActivities.length === 0) return null; + + // Separate exploration from edit activities + const exploration = groupActivities.filter( + (a) => + a.type !== 'vault_write' && + a.type !== 'vault_edit' && + a.type !== 'vault_rename' && + a.type !== 'vault_delete', + ); + const edits = groupActivities.filter( + (a) => + a.type === 'vault_write' || + a.type === 'vault_edit' || + a.type === 'vault_rename' || + a.type === 'vault_delete', + ); + + return ( +
      + {exploration.length > 0 && ( + + )} + {edits.map((edit) => ( + + ))} +
      + ); + } + + default: + return null; + } + })} +
      + ); +} +``` + +### 5. AssistantToolMessageGroupItem.tsx Changes + +Add conditional rendering based on `contentBlocks` presence: + +```typescript +// Inside the render, replace the current 3-section layout: + +// Check if ANY message in the group has contentBlocks +const hasContentBlocks = messages.some( + (m) => m.role === 'assistant' && m.contentBlocks && m.contentBlocks.length > 0, +); + +return ( +
      + {hasContentBlocks ? ( + // NEW: Interspersed layout + messages.map((message) => { + if (message.role === 'assistant' && message.contentBlocks) { + return ( + + ); + } + return null; + }) + ) : ( + // LEGACY: Grouped layout for old conversations + <> + {allActivities.length > 0 && ( + + )} + {editActivities.length > 0 && ( +
      + {editActivities.map((activity) => ( + + ))} +
      + )} + {messages.map((message) => { + if (message.role === 'assistant') { + const displayContent = getDisplayContent(message.content || '', allActivities.length > 0); + if (!displayContent) return null; + return ( +
      + +
      + ); + } + return null; + })} + + )} + {messages.length > 0 && } +
      +); +``` + +## Backward Compatibility + +- `contentBlocks` is **optional** on `ChatAssistantMessage` +- Old saved conversations have `contentBlocks: undefined` → legacy grouped rendering +- New conversations get `contentBlocks` populated during streaming → interspersed rendering +- Flat `content: string` is always kept in sync (computed from text blocks) for: + - Search/filter functionality + - Non-backend providers that don't emit contentBlock deltas + - Any code that reads `message.content` directly +- No database migration needed + +## Edge Cases + +### Abort Mid-Stream +When the user clicks Stop: +- `onComplete` fires → flushes any pending content block +- Partially accumulated text block gets emitted as-is +- Activity groups with running activities render with the running state (timer keeps going until timeout) + +### Empty Text Blocks +If `onToolStart` fires immediately after `onToolEnd` (back-to-back tools with no text in between), no empty text block is created — the activity groups just coalesce. + +### Thinking Blocks +Thinking activities get their own `activity_group` block. They appear inline in the chronological flow, shown as a collapsible "Thought for Xs" item between text. + +### Direct API Provider +When using direct API providers (OpenAI, Anthropic direct), `contentBlocks` won't be populated since those providers don't go through BackendProvider. They'll use the legacy grouped layout, which is fine. + +### Multi-turn Agent +Each turn of the agent produces its own text and tools. Since `contentBlocks` accumulates across the entire response, multi-turn flows naturally produce: +``` +[Turn 1 activities] +Turn 1 text... +[Turn 2 activities] +Turn 2 text... +``` + +## CSS Styling + +Add minimal styling for the interspersed layout: + +```css +.smtcmp-interspersed-content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.smtcmp-interspersed-group { + margin: 4px 0; +} +``` + +The existing `ActivityAccordion` and `EditDiffBlock` styles work as-is since they're self-contained components. + +## E2E Testing Instructions (CDP) + +### Prerequisites +- Obsidian running with test vault (`~/yes-chef-test/`) +- Backend running (`cd ~/claudsidian/backend && npm run dev`) +- Plugin built and installed (`cd ~/claudsidian && node esbuild.config.mjs production`) +- CDP port 9222 open on Obsidian + +### Test 1: Basic Interspersed Layout +1. Open new chat via `app.commands.executeCommandById('claudsidian:open-new-chat')` +2. Send: "What are the mother sauces? Create a note about them." +3. **Expected**: Response shows interleaved: + - `[Explored 1 search]` (search_cookbooks) + - Text paragraph explaining mother sauces + - `[Created mother-sauces.md +N lines]` with diff + - Text paragraph confirming creation +4. **Verify**: Activities appear between text, NOT all grouped at top + +### Test 2: Multi-Tool Interspersed +1. Send: "Compare braising in Peterson vs CIA Pro Chef, then create a comparison note" +2. **Expected**: Multiple activity groups interspersed: + - `[Explored 2 searches]` + - Text comparing the sources + - `[Read 1 file]` (if it reads existing notes) + - More text + - `[Created comparison.md]` + - Final text +3. **Verify**: Each activity group appears at the chronological point it was executed + +### Test 3: Backward Compat - Old Conversation +1. Load an existing conversation that was created before the feature +2. **Expected**: Legacy grouped layout (all activities at top, text at bottom) +3. **Verify**: No errors, no visual regression + +### Test 4: Streaming Behavior +1. Send a complex query and observe during streaming +2. **Expected**: + - Activity accordion appears inline as tools start + - Text streams below each activity group + - New activity groups appear between text paragraphs +3. **Verify**: No layout jumps or flashing during streaming + +### Test 5: Stop Mid-Stream +1. Send complex query, click Stop after 2-3 tool calls +2. **Expected**: Partial content renders correctly — whatever blocks were completed show up +3. **Verify**: No stuck spinners, no blank areas + +### Test 6: Thinking Interspersed +1. Send a complex query that triggers extended thinking +2. **Expected**: "Thought for Xs" appears inline in the chronological flow +3. **Verify**: Thinking block is between text, not just in the top accordion + +### UI Visual Verification +For each test, verify: +- [ ] Activity groups have proper accordion expand/collapse +- [ ] Edit diffs show green/red highlighting correctly +- [ ] Clickable file links work (`[[filename]]` opens in editor) +- [ ] Timer shows and stops correctly on activity items +- [ ] Text rendering (markdown, code blocks, citations) is unchanged +- [ ] No duplicate activities (check activity IDs) +- [ ] Undo button works on edit activities + +### CDP Quick Eval Script Pattern +```javascript +// In CDP console (ws://127.0.0.1:9222/devtools/page/{TARGET_ID}): +// Check if interspersed content is rendered +const blocks = document.querySelectorAll('.smtcmp-interspersed-content'); +console.log('Interspersed containers:', blocks.length); + +// Check block ordering +const content = document.querySelector('.smtcmp-interspersed-content'); +if (content) { + const children = Array.from(content.children); + children.forEach((child, i) => { + const isActivity = child.classList.contains('smtcmp-interspersed-group'); + const isText = child.classList.contains('smtcmp-chat-messages-assistant'); + console.log(`Block ${i}: ${isActivity ? 'ACTIVITY' : isText ? 'TEXT' : 'OTHER'}`); + }); +} +``` + +## Files to Modify + +| File | Action | +|------|--------| +| `src/types/chat.ts` | Add `ContentBlock` type, add `contentBlocks?` to `ChatAssistantMessage` | +| `src/types/llm/response.ts` | Add `contentBlock?: ContentBlock` to stream delta | +| `src/core/backend/BackendProvider.ts` | Track block boundaries, emit `contentBlock` deltas | +| `src/utils/chat/responseGenerator.ts` | Add `mergeContentBlocks()`, compute flat `content` | +| `src/components/chat-view/InterspersedContent.tsx` | NEW: interspersed renderer component | +| `src/components/chat-view/AssistantToolMessageGroupItem.tsx` | Conditional: contentBlocks → interspersed, else legacy | +| `src/styles.css` | Add `.smtcmp-interspersed-content` and `.smtcmp-interspersed-group` styles | diff --git a/src/core/backend/WebSocketClient.ts b/src/core/backend/WebSocketClient.ts index f8f003d9..1dbaf047 100644 --- a/src/core/backend/WebSocketClient.ts +++ b/src/core/backend/WebSocketClient.ts @@ -336,7 +336,9 @@ export class WebSocketClient { case 'complete': { const handler = this.activeHandlers.get(msg.requestId); handler?.onComplete?.(msg.result); - this.activeHandlers.delete(msg.requestId); + // Delay handler cleanup to allow lingering tool_end events to arrive + // (external MCP tool_end events may be sent slightly after complete) + setTimeout(() => this.activeHandlers.delete(msg.requestId), 1000); break; } case 'error': { From 09474e8d27bb0186068765bba2048b2fb82b0149 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:05:09 -0500 Subject: [PATCH 33/40] Fix exit code 1 crash: remove thinking option, suppress post-completion error - Remove `thinking: { type: 'enabled', budgetTokens: 10000 }` option that was causing the Agent SDK CLI to fail silently (2s empty response + exit 1) - Track `completedSuccessfully` flag to suppress the spurious "process exited with code 1" error the SDK throws after normal completion Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 2256faa4..840fc9a0 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -240,6 +240,10 @@ export async function* runAgent( // (vault tools push their own tool_end via eventQueue; external MCP tools don't) const pendingTools: string[] = []; + // Track if the agent completed successfully so we can suppress the + // "process exited with code 1" error the SDK throws after completion + let completedSuccessfully = false; + function* drainToolEvents(): Generator { while (eventQueue.length > 0) { const event = eventQueue.shift()!; @@ -283,7 +287,6 @@ export async function* runAgent( abortController, permissionMode: 'bypassPermissions' as const, includePartialMessages: true, - thinking: { type: 'enabled', budgetTokens: 10000 }, stderr: (data: string) => { heartbeat(); // stderr output = activity logger.warn(`CLI stderr: ${data.trimEnd()}`); @@ -346,6 +349,7 @@ export async function* runAgent( yield* closePendingTools(); if (message.subtype === 'success') { + completedSuccessfully = true; logger.info('Agent completed successfully'); yield { type: 'complete', result: message.result || '' }; } else { @@ -371,13 +375,20 @@ export async function* runAgent( const errMsg = err instanceof Error ? err.message : 'Unknown error'; const errStack = err instanceof Error ? err.stack : ''; - logger.error('Agent error:', errMsg); - logger.error('Agent error stack:', errStack); - yield { - type: 'error', - code: 'AGENT_ERROR', - message: errMsg, - }; + + // The SDK throws "process exited with code 1" after the agent has already + // completed successfully. Suppress this spurious error. + if (completedSuccessfully && errMsg.includes('exited with code')) { + logger.warn(`Ignoring post-completion SDK error: ${errMsg}`); + } else { + logger.error('Agent error:', errMsg); + logger.error('Agent error stack:', errStack); + yield { + type: 'error', + code: 'AGENT_ERROR', + message: errMsg, + }; + } } finally { clearInterval(activityCheck); } From 346f35167f69ff36c88f84bb74b619af1e6e57d1 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:06:35 -0500 Subject: [PATCH 34/40] Enable adaptive thinking for Opus 4.6 Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 840fc9a0..e89a6917 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -287,6 +287,7 @@ export async function* runAgent( abortController, permissionMode: 'bypassPermissions' as const, includePartialMessages: true, + thinking: { type: 'adaptive' }, stderr: (data: string) => { heartbeat(); // stderr output = activity logger.warn(`CLI stderr: ${data.trimEnd()}`); From 0c846377822d565b71e9703ecdccafa0c95e2384 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:13:34 -0500 Subject: [PATCH 35/40] Remove thinking option to isolate exit code 1 issue Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index e89a6917..840fc9a0 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -287,7 +287,6 @@ export async function* runAgent( abortController, permissionMode: 'bypassPermissions' as const, includePartialMessages: true, - thinking: { type: 'adaptive' }, stderr: (data: string) => { heartbeat(); // stderr output = activity logger.warn(`CLI stderr: ${data.trimEnd()}`); From 416cf34fe0e49be8d76562a2a90e7e987cd9f198 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:23:35 -0500 Subject: [PATCH 36/40] Re-enable adaptive thinking for Opus 4.6 Root cause of empty responses was ANTHROPIC_API_KEY env var on Railway overriding the working CLAUDE_CODE_OAUTH_TOKEN. The thinking option itself is fine. Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 840fc9a0..e89a6917 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -287,6 +287,7 @@ export async function* runAgent( abortController, permissionMode: 'bypassPermissions' as const, includePartialMessages: true, + thinking: { type: 'adaptive' }, stderr: (data: string) => { heartbeat(); // stderr output = activity logger.warn(`CLI stderr: ${data.trimEnd()}`); From e39add34fb97877b306ebd7d3ece25a0412f92f8 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:09:01 -0500 Subject: [PATCH 37/40] Increase maxTurns to 50, add truncation notice, reduce repetition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - maxTurns 25→50 to support complex multi-source research queries - Detect error_max_turns and append truncation notice to response - Add system prompt guidance: batch tool calls 5-8 at a time, present final synthesis instead of repeating findings in multiple formats Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index e89a6917..2eaa4616 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -56,7 +56,9 @@ When the user asks about cooking techniques, recipes, ingredients, or food scien - Be concise but helpful - Explain what changes you're making - When citing cookbook sources, always include the exact page numbers and preserve any links from the tool results -- If uncertain, ask for clarification`; +- If uncertain, ask for clarification +- When integrating research results, present the final synthesis — avoid restating the same finding in multiple formats within one response +- For complex multi-topic research, work in batches of 5-8 tool calls at a time rather than launching dozens in parallel. Complete one batch, synthesize results, then proceed to the next batch.`; interface Skill { name: string; @@ -154,7 +156,7 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { } const DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; -const MAX_TURNS = 25; // Complex cookbook queries need many tool calls +const MAX_TURNS = 50; // Complex research queries can use 30-40+ tool calls const INACTIVITY_TIMEOUT_MS = 600_000; // 10 minutes of no activity = dead /** @@ -353,6 +355,12 @@ export async function* runAgent( completedSuccessfully = true; logger.info('Agent completed successfully'); yield { type: 'complete', result: message.result || '' }; + } else if (message.subtype === 'error_max_turns') { + completedSuccessfully = true; // Partial result is still valid + logger.warn(`Agent hit max turns limit (${MAX_TURNS})`); + // Send any partial result, then add a note about the truncation + yield { type: 'text_delta', text: '\n\n---\n*Response was truncated because the query required too many steps. You can ask me to continue where I left off.*\n' }; + yield { type: 'complete', result: (message as any).result || '' }; } else { const errors = 'errors' in message ? (message as any).errors : []; logger.error(`Agent stopped: ${message.subtype}`, errors); From 2dc52487afa5c229b0e9cd4cbc59d246fea5b469 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:58:24 -0500 Subject: [PATCH 38/40] Add interspersed activity layout and rendered markdown preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace grouped activity layout (all activities at top, text at bottom) with chronologically interspersed layout — text and activities appear in the order they happen during agent execution, like Cursor. Also restore rendered markdown preview in edit diff blocks with a Raw/Rendered toggle button, defaulting to rendered view. Co-Authored-By: Claude Opus 4.6 --- .../AssistantToolMessageGroupItem.tsx | 152 +++++++++++------- src/components/chat-view/EditDiffBlock.tsx | 32 +++- .../chat-view/InterspersedContent.tsx | 93 +++++++++++ src/core/backend/BackendProvider.ts | 54 ++++++- src/types/chat.ts | 11 ++ src/types/llm/response.ts | 3 +- src/utils/chat/responseGenerator.ts | 38 ++++- styles.css | 24 +++ 8 files changed, 339 insertions(+), 68 deletions(-) create mode 100644 src/components/chat-view/InterspersedContent.tsx diff --git a/src/components/chat-view/AssistantToolMessageGroupItem.tsx b/src/components/chat-view/AssistantToolMessageGroupItem.tsx index c8b7d528..f029fc2e 100644 --- a/src/components/chat-view/AssistantToolMessageGroupItem.tsx +++ b/src/components/chat-view/AssistantToolMessageGroupItem.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import React, { useMemo } from 'react' import { ActivityEvent, @@ -13,6 +13,7 @@ import AssistantMessageContent from './AssistantMessageContent' import AssistantMessageReasoning from './AssistantMessageReasoning' import AssistantToolMessageGroupActions from './AssistantToolMessageGroupActions' import EditDiffBlock from './EditDiffBlock' +import InterspersedContent from './InterspersedContent' import ToolMessage from './ToolMessage' /** @@ -233,73 +234,104 @@ export default function AssistantToolMessageGroupItem({ return { allActivities: activities, editActivities: Array.from(dedupedEdits.values()) } }, [messages]) + // Check if any message in the group has interspersed content blocks + const hasContentBlocks = messages.some( + (m) => m.role === 'assistant' && m.contentBlocks && m.contentBlocks.length > 0, + ) + return (
      - {/* Activity accordion (Cursor-style) - shows before content */} - {allActivities.length > 0 && ( - - )} - - {/* Edit diff blocks for file modifications */} - {editActivities.length > 0 && ( -
      - {editActivities.map((activity) => ( - - ))} -
      - )} - - {messages.map((message) => { - if (message.role === 'assistant') { - // Get display content - filters out tool result summaries when we have activities - const displayContent = getDisplayContent( - message.content || '', - allActivities.length > 0, - ) - - // Don't render if content is empty after filtering - if (!message.reasoning && !message.annotations && !displayContent) { - return null - } - - return ( -
      - {message.reasoning && ( - - )} - {message.annotations && ( - - )} - {displayContent && ( - { + if (message.role === 'assistant' && message.contentBlocks && message.contentBlocks.length > 0) { + return ( + + {message.reasoning && ( + + )} + {message.annotations && ( + + )} + - )} + + ) + } + return null + }) + ) : ( + // Legacy grouped layout for old conversations / non-backend providers + <> + {allActivities.length > 0 && ( + + )} + + {editActivities.length > 0 && ( +
      + {editActivities.map((activity) => ( + + ))}
      - ) - } + )} - // For tool messages, only show if we don't have activities - // (activities replace the old ToolMessage display) - if (allActivities.length > 0) { - return null - } + {messages.map((message) => { + if (message.role === 'assistant') { + const displayContent = getDisplayContent( + message.content || '', + allActivities.length > 0, + ) - return ( -
      - -
      - ) - })} + if (!message.reasoning && !message.annotations && !displayContent) { + return null + } + + return ( +
      + {message.reasoning && ( + + )} + {message.annotations && ( + + )} + {displayContent && ( + + )} +
      + ) + } + + if (allActivities.length > 0) { + return null + } + + return ( +
      + +
      + ) + })} + + )} {messages.length > 0 && ( )} diff --git a/src/components/chat-view/EditDiffBlock.tsx b/src/components/chat-view/EditDiffBlock.tsx index 9bde2b8b..6e0a5130 100644 --- a/src/components/chat-view/EditDiffBlock.tsx +++ b/src/components/chat-view/EditDiffBlock.tsx @@ -7,13 +7,14 @@ * - Undo button for revert */ -import { ChevronDown, ChevronRight, Undo2 } from 'lucide-react' +import { ChevronDown, ChevronRight, Eye, Undo2 } from 'lucide-react' import { memo, useCallback, useState } from 'react' import { TFile } from 'obsidian' import type { ActivityEvent } from '../../types/chat' import { getEditHistory } from '../../core/backend/EditHistory' import { useApp } from '../../contexts/app-context' +import { ObsidianMarkdown } from './ObsidianMarkdown' export interface EditDiffBlockProps { activity: ActivityEvent @@ -97,6 +98,7 @@ const EditDiffBlock = memo(function EditDiffBlock({ }: EditDiffBlockProps) { const app = useApp() const [isOpen, setIsOpen] = useState(true) + const [isPreviewMode, setIsPreviewMode] = useState(true) const [isReverting, setIsReverting] = useState(false) const [reverted, setReverted] = useState(false) @@ -176,6 +178,17 @@ const EditDiffBlock = memo(function EditDiffBlock({ {deletions > 0 && -{deletions}}
      + {!reverted && (
      )}
      diff --git a/src/components/chat-view/InterspersedContent.tsx b/src/components/chat-view/InterspersedContent.tsx new file mode 100644 index 00000000..619c3a0c --- /dev/null +++ b/src/components/chat-view/InterspersedContent.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react' + +import type { ActivityEvent, ChatMessage, ContentBlock } from '../../types/chat' + +import ActivityAccordion from './ActivityAccordion' +import AssistantMessageContent from './AssistantMessageContent' +import EditDiffBlock from './EditDiffBlock' + +interface InterspersedContentProps { + contentBlocks: ContentBlock[] + activities: ActivityEvent[] + isStreaming: boolean + contextMessages: ChatMessage[] + isApplying: boolean + onApply: (blockToApply: string, chatMessages: ChatMessage[]) => void +} + +const EDIT_ACTIVITY_TYPES = new Set([ + 'vault_write', + 'vault_edit', + 'vault_rename', + 'vault_delete', +]) + +export default function InterspersedContent({ + contentBlocks, + activities, + isStreaming, + contextMessages, + isApplying, + onApply, +}: InterspersedContentProps) { + // Build activity lookup map + const activityMap = useMemo( + () => new Map(activities.map((a) => [a.id, a])), + [activities], + ) + + return ( +
      + {contentBlocks.map((block, idx) => { + const isLastBlock = idx === contentBlocks.length - 1 + + switch (block.type) { + case 'text': + return ( +
      + +
      + ) + + case 'activity_group': { + const groupActivities = block.activityIds + .map((id) => activityMap.get(id)) + .filter((a): a is ActivityEvent => a !== undefined) + + if (groupActivities.length === 0) return null + + // Separate exploration from edit activities + const exploration = groupActivities.filter( + (a) => !EDIT_ACTIVITY_TYPES.has(a.type), + ) + const edits = groupActivities.filter( + (a) => EDIT_ACTIVITY_TYPES.has(a.type), + ) + + return ( +
      + {exploration.length > 0 && ( + + )} + {edits.map((edit) => ( + + ))} +
      + ) + } + + default: + return null + } + })} +
      + ) +} diff --git a/src/core/backend/BackendProvider.ts b/src/core/backend/BackendProvider.ts index 1fb6c3fa..7789d683 100644 --- a/src/core/backend/BackendProvider.ts +++ b/src/core/backend/BackendProvider.ts @@ -8,7 +8,7 @@ import { BaseLLMProvider } from '../llm/base'; import { LLMRateLimitExceededException } from '../llm/exception'; import type { ChatModel } from '../../types/chat-model.types'; -import type { ActivityEvent, ActivityType } from '../../types/chat'; +import type { ActivityEvent, ActivityType, ContentBlock } from '../../types/chat'; import type { LLMOptions, LLMRequestNonStreaming, @@ -191,11 +191,48 @@ export class BackendProvider extends BaseLLMProvider { } }; + // Interspersed layout: track current block type for content boundaries + let currentBlockType: 'text' | 'activity_group' | null = null; + let currentTextBlock = ''; + let currentActivityGroup: string[] = []; + + const enqueueContentBlock = (block: ContentBlock) => { + enqueueChunk({ + id: requestId, + object: 'chat.completion.chunk', + model: 'backend', + choices: [{ delta: { contentBlock: block }, finish_reason: null }], + }); + }; + + const flushTextBlock = () => { + if (currentBlockType === 'text' && currentTextBlock) { + enqueueContentBlock({ type: 'text', text: currentTextBlock }); + currentTextBlock = ''; + } + }; + + const flushActivityGroup = () => { + if (currentBlockType === 'activity_group' && currentActivityGroup.length > 0) { + enqueueContentBlock({ type: 'activity_group', activityIds: [...currentActivityGroup] }); + currentActivityGroup = []; + } + }; + // Set up event handlers and send prompt const requestId = await this.wsClient.sendPrompt( prompt, { onTextDelta: (text: string) => { + // Track block boundary: close pending activity group when text starts + if (currentBlockType !== 'text') { + flushActivityGroup(); + currentBlockType = 'text'; + currentTextBlock = ''; + } + currentTextBlock += text; + + // Emit text delta for live streaming display const chunk: LLMResponseStreaming = { id: requestId, object: 'chat.completion.chunk', @@ -211,11 +248,18 @@ export class BackendProvider extends BaseLLMProvider { }, onToolStart: (name: string, input: Record) => { + // Track block boundary: flush pending text when tool starts + flushTextBlock(); + currentBlockType = 'activity_group'; + // Create a tool call entry with backend__ prefix for UI display const index = toolCalls.size; const toolId = `backend-${requestId}-${index}`; const activityId = `activity-${requestId}-${index}`; + // Add to current activity group for interspersed layout + currentActivityGroup.push(activityId); + toolCalls.set(index, { id: toolId, name: `backend__${name}`, // Add prefix so UI knows it's from backend @@ -310,8 +354,12 @@ export class BackendProvider extends BaseLLMProvider { onThinking: (text: string) => { // Start new thinking activity if not already active if (!currentThinkingId) { + // Track block boundary: thinking acts as an activity + flushTextBlock(); + currentBlockType = 'activity_group'; currentThinkingId = `thinking-${requestId}-${Date.now()}`; thinkingStartTime = Date.now(); + currentActivityGroup.push(currentThinkingId); } // Create thinking activity event @@ -341,6 +389,10 @@ export class BackendProvider extends BaseLLMProvider { }, onComplete: (result: string) => { + // Flush any pending content blocks for interspersed layout + flushTextBlock(); + flushActivityGroup(); + // Finalize any pending thinking activity if (currentThinkingId && thinkingStartTime) { const thinkingActivity: ActivityEvent = { diff --git a/src/types/chat.ts b/src/types/chat.ts index 27df0d84..f8c364ca 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -61,6 +61,15 @@ export interface ActivityEvent { results?: string[] // File paths or search results } +/** + * A block of content in the chronological stream. + * Text blocks coalesce adjacent text deltas. + * Activity groups coalesce adjacent tool calls. + */ +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'activity_group'; activityIds: string[] } + export type ChatUserMessage = { role: 'user' content: SerializedEditorState | null @@ -74,6 +83,7 @@ export type ChatUserMessage = { export type ChatAssistantMessage = { role: 'assistant' content: string + contentBlocks?: ContentBlock[] // Chronological sequence of text and activity groups reasoning?: string annotations?: Annotation[] toolCallRequests?: ToolCallRequest[] @@ -116,6 +126,7 @@ export type SerializedChatUserMessage = { export type SerializedChatAssistantMessage = { role: 'assistant' content: string + contentBlocks?: ContentBlock[] // Chronological sequence of text and activity groups reasoning?: string annotations?: Annotation[] toolCallRequests?: ToolCallRequest[] diff --git a/src/types/llm/response.ts b/src/types/llm/response.ts index ba74c83f..e794c2f5 100644 --- a/src/types/llm/response.ts +++ b/src/types/llm/response.ts @@ -1,7 +1,7 @@ // These types are based on the OpenRouter API specification // https://openrouter.ai/docs/api-reference/overview#responses -import type { ActivityEvent } from '../chat' +import type { ActivityEvent, ContentBlock } from '../chat' export type LLMResponseBase = { id: string @@ -50,6 +50,7 @@ type StreamingChoice = { annotations?: Annotation[] tool_calls?: ToolCallDelta[] activity?: ActivityEvent // For Cursor-style activity streaming + contentBlock?: ContentBlock // For interspersed layout } error?: Error } diff --git a/src/utils/chat/responseGenerator.ts b/src/utils/chat/responseGenerator.ts index e7a106f4..626774c7 100644 --- a/src/utils/chat/responseGenerator.ts +++ b/src/utils/chat/responseGenerator.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid' import { BaseLLMProvider } from '../../core/llm/base' import { McpManager } from '../../core/mcp/mcpManager' -import { ActivityEvent, ChatMessage, ChatToolMessage } from '../../types/chat' +import { ActivityEvent, ChatMessage, ChatToolMessage, ContentBlock } from '../../types/chat' import { ChatModel } from '../../types/chat-model.types' import { RequestTool } from '../../types/llm/request' import { @@ -249,6 +249,7 @@ export class ResponseGenerator { const toolCalls = chunk.choices[0]?.delta?.tool_calls const annotations = chunk.choices[0]?.delta?.annotations const activity = chunk.choices[0]?.delta?.activity + const contentBlock = chunk.choices[0]?.delta?.contentBlock const updatedToolCalls = toolCalls ? this.mergeToolCallDeltas(toolCalls, responseToolCalls) @@ -296,6 +297,9 @@ export class ResponseGenerator { activities: activity ? this.mergeActivities(message.activities, activity) : message.activities, + contentBlocks: contentBlock + ? this.mergeContentBlocks(message.contentBlocks, contentBlock) + : message.contentBlocks, metadata: { ...message.metadata, usage: chunk.usage ?? message.metadata?.usage, @@ -381,6 +385,38 @@ export class ResponseGenerator { return mergedAnnotations } + /** + * Merge content blocks - coalesces adjacent text or adjacent activity groups + */ + private mergeContentBlocks( + prevBlocks?: ContentBlock[], + newBlock?: ContentBlock, + ): ContentBlock[] | undefined { + if (!newBlock) return prevBlocks + if (!prevBlocks) return [newBlock] + + const last = prevBlocks[prevBlocks.length - 1] + + // Coalesce adjacent text blocks + if (newBlock.type === 'text' && last?.type === 'text') { + return [ + ...prevBlocks.slice(0, -1), + { type: 'text' as const, text: last.text + newBlock.text }, + ] + } + + // Coalesce adjacent activity groups + if (newBlock.type === 'activity_group' && last?.type === 'activity_group') { + return [ + ...prevBlocks.slice(0, -1), + { type: 'activity_group' as const, activityIds: [...last.activityIds, ...newBlock.activityIds] }, + ] + } + + // Different type = new block + return [...prevBlocks, newBlock] + } + /** * Merge activity events - updates existing activities or adds new ones */ diff --git a/styles.css b/styles.css index a47f083d..8a5aa0c9 100644 --- a/styles.css +++ b/styles.css @@ -2097,6 +2097,17 @@ button.smtcmp-chat-input-model-select { } } +/* Interspersed Content Layout */ +.smtcmp-interspersed-content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.smtcmp-interspersed-group { + margin: 4px 0; +} + /* Edit Diff Block */ .smtcmp-edit-blocks { display: flex; @@ -2195,12 +2206,25 @@ button.smtcmp-chat-input-model-select { padding: var(--size-2-2) var(--size-4-1); } +.smtcmp-edit-diff-view-toggle { + display: flex; + align-items: center; + gap: var(--size-2-1); + font-size: var(--font-smallest); + color: var(--text-muted); + padding: var(--size-2-1) var(--size-2-2); +} + .smtcmp-edit-diff-body { border-top: var(--input-border-width) solid var(--background-modifier-border); max-height: 300px; overflow-y: auto; } +.smtcmp-edit-diff-rendered { + padding: var(--size-4-2); +} + .smtcmp-diff-content { font-family: var(--font-monospace); font-size: var(--font-smallest); From 86af615ba703119397503925e68dd513e0003548 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:47:01 -0500 Subject: [PATCH 39/40] Remove wiki-link brackets from activity UI labels Strip [[]] from file names in edit diff headers, activity item links, and activity accordion labels. The link styling already distinguishes clickable filenames without needing bracket decoration. Co-Authored-By: Claude Opus 4.6 --- src/components/chat-view/ActivityAccordion.tsx | 8 ++++---- src/components/chat-view/ActivityItem.tsx | 2 +- src/components/chat-view/EditDiffBlock.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/chat-view/ActivityAccordion.tsx b/src/components/chat-view/ActivityAccordion.tsx index 825a8b1c..10c3dccd 100644 --- a/src/components/chat-view/ActivityAccordion.tsx +++ b/src/components/chat-view/ActivityAccordion.tsx @@ -81,11 +81,11 @@ function getActivityLabel(activity: ActivityEvent): string { switch (activity.type) { case 'vault_read': - return `Read [[${displayName}]]` + return `Read ${displayName}` case 'vault_write': - return `Created [[${displayName}]]` + return `Created ${displayName}` case 'vault_edit': - return `Edited [[${displayName}]]` + return `Edited ${displayName}` case 'vault_search': return `Searched "${activity.toolInput?.query || ''}"` case 'vault_grep': @@ -97,7 +97,7 @@ function getActivityLabel(activity: ActivityEvent): string { case 'vault_rename': return `Moved ${activity.oldPath?.split('/').pop()} → ${activity.newPath?.split('/').pop()}` case 'vault_delete': - return `Deleted [[${displayName}]]` + return `Deleted ${displayName}` case 'web_search': return `Web search: "${activity.toolInput?.query || ''}"` case 'search_cookbooks': diff --git a/src/components/chat-view/ActivityItem.tsx b/src/components/chat-view/ActivityItem.tsx index c3b47ae5..aed92c32 100644 --- a/src/components/chat-view/ActivityItem.tsx +++ b/src/components/chat-view/ActivityItem.tsx @@ -59,7 +59,7 @@ function FileLink({ filePath, displayName }: { filePath: string; displayName?: s onClick={handleClick} title={filePath} > - [[{name}]] + {name} ) } diff --git a/src/components/chat-view/EditDiffBlock.tsx b/src/components/chat-view/EditDiffBlock.tsx index 6e0a5130..d7cca0b3 100644 --- a/src/components/chat-view/EditDiffBlock.tsx +++ b/src/components/chat-view/EditDiffBlock.tsx @@ -170,7 +170,7 @@ const EditDiffBlock = memo(function EditDiffBlock({ }} title={activity.filePath} > - [[{displayName}]] + {displayName} From 366feb9db17b6f312adfa1398c364e7972ed2296 Mon Sep 17 00:00:00 2001 From: ki-cooley <46787270+ki-cooley@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:40:01 -0500 Subject: [PATCH 40/40] Fix image inputs and @ file mentions for backend provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add images field to PromptMessage protocol (both frontend and backend) - Skip file reading/RAG in PromptGenerator for backend provider — pass file paths instead so the agent reads via vault_read - Extract images from request in BackendProvider, send separately from prompt text via WebSocket - Forward images through server.ts to agent.ts, construct multimodal Anthropic content blocks when images are present - Add console.log tracing for image flow debugging Co-Authored-By: Claude Opus 4.6 --- backend/src/agent.ts | 43 +++++++- backend/src/protocol.ts | 2 + backend/src/server.ts | 3 +- src/components/chat-view/Chat.tsx | 22 ++++ .../chat-view/chat-input/ChatUserInput.tsx | 6 ++ src/core/backend/BackendProvider.ts | 66 +++++++++--- src/core/backend/WebSocketClient.ts | 4 +- src/core/backend/protocol.ts | 2 + src/utils/chat/promptGenerator.ts | 102 +++++++++++++++++- 9 files changed, 230 insertions(+), 20 deletions(-) diff --git a/backend/src/agent.ts b/backend/src/agent.ts index 2eaa4616..0a496377 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -58,7 +58,14 @@ When the user asks about cooking techniques, recipes, ingredients, or food scien - When citing cookbook sources, always include the exact page numbers and preserve any links from the tool results - If uncertain, ask for clarification - When integrating research results, present the final synthesis — avoid restating the same finding in multiple formats within one response -- For complex multi-topic research, work in batches of 5-8 tool calls at a time rather than launching dozens in parallel. Complete one batch, synthesize results, then proceed to the next batch.`; +- For complex multi-topic research, work in batches of 5-8 tool calls at a time rather than launching dozens in parallel. Complete one batch, synthesize results, then proceed to the next batch. + +## Memory Management +You have a persistent memory file (.claude/memory.md) loaded into your context. +- After learning user preferences, project context, or important decisions, use vault_edit or vault_write to update .claude/memory.md +- Keep it concise (<500 words), organized with ## headings +- Sections: ## User Preferences, ## Projects, ## Key Decisions, ## Conventions +- Don't store conversation-specific details — only persistent knowledge`; interface Skill { name: string; @@ -143,6 +150,18 @@ async function buildSystemPrompt(bridge: VaultBridge): Promise { // .claude/instructions.md doesn't exist, that's fine } + // Load persistent memory + try { + const memory = await bridge.read('.claude/memory.md'); + if (memory && memory.trim()) { + systemPrompt += `\n\n## Persistent Memory\n\nThis is your long-term memory from past conversations. Use it for context continuity:\n\n${memory}`; + logger.info('Loaded .claude/memory.md'); + } + } catch (e) { + // No memory file yet — will be created when agent first updates memory + logger.debug('No .claude/memory.md found'); + } + // Load custom skills const skills = await loadSkills(bridge); if (skills.length > 0) { @@ -178,7 +197,8 @@ export async function* runAgent( context?: AgentContext, signal?: AbortSignal, customSystemPrompt?: string, - model?: string + model?: string, + images?: Array<{ mimeType: string; base64Data: string }> ): AsyncGenerator { const selectedModel = model || DEFAULT_MODEL; logger.info(`Using model: ${selectedModel}`); @@ -229,10 +249,27 @@ export async function* runAgent( allowedTools.push('WebSearch'); // Streaming input mode (required for mcpServers) + // Build the user message. When images are present, use multimodal content blocks. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userMessage: any = { role: 'user', content: fullPrompt }; + if (images && images.length > 0) { + userMessage.content = [ + ...images.map((img) => ({ + type: 'image', + source: { + type: 'base64', + media_type: img.mimeType, + data: img.base64Data, + }, + })), + { type: 'text', text: fullPrompt }, + ]; + } + async function* singlePrompt() { yield { type: 'user' as const, - message: { role: 'user' as const, content: fullPrompt }, + message: userMessage, parent_tool_use_id: null, session_id: '', }; diff --git a/backend/src/protocol.ts b/backend/src/protocol.ts index 6961555d..34bca8f8 100644 --- a/backend/src/protocol.ts +++ b/backend/src/protocol.ts @@ -40,6 +40,8 @@ export interface PromptMessage { systemPrompt?: string; /** Model to use for this request (e.g., 'claude-opus-4-5-20250514') */ model?: string; + /** Images to include as multimodal content */ + images?: Array<{ mimeType: string; base64Data: string }>; } /** Response to an RPC request from server */ diff --git a/backend/src/server.ts b/backend/src/server.ts index 1daeb0f0..b1070300 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -115,7 +115,8 @@ class ConnectionHandler { msg.context, abortController.signal, msg.systemPrompt, - msg.model + msg.model, + msg.images )) { if (abortController.signal.aborted) { logger.info(`Request ${msg.id} was cancelled`); diff --git a/src/components/chat-view/Chat.tsx b/src/components/chat-view/Chat.tsx index 2c403fe9..10a03197 100644 --- a/src/components/chat-view/Chat.tsx +++ b/src/components/chat-view/Chat.tsx @@ -169,6 +169,12 @@ const Chat = forwardRef((props, ref) => { } setCurrentConversationId(conversationId) setChatMessages(conversation) + // Scroll to latest message after React renders the loaded conversation + requestAnimationFrame(() => { + requestAnimationFrame(() => { + forceScrollToBottom() + }) + }) const newInputMessage = getNewInputMessage(app) setInputMessage(newInputMessage) setFocusedMessageId(newInputMessage.id) @@ -427,6 +433,17 @@ const Chat = forwardRef((props, ref) => { }) }, [submitChatMutation, chatMessages, currentConversationId]) + // Scroll to bottom when mobile keyboard opens/closes + useEffect(() => { + const vv = window.visualViewport + if (!vv) return + const handleResize = () => { + requestAnimationFrame(() => autoScrollToBottom()) + } + vv.addEventListener('resize', handleResize) + return () => vv.removeEventListener('resize', handleResize) + }, [autoScrollToBottom]) + useEffect(() => { setFocusedMessageId(inputMessage.id) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -712,6 +729,11 @@ const Chat = forwardRef((props, ref) => { }} onSubmit={(content, useVaultSearch) => { if (editorStateToPlainText(content).trim() === '') return + const imageMentionables = inputMessage.mentionables.filter(m => m.type === 'image') + console.log('[ImageFlow] onSubmit: inputMessage has', inputMessage.mentionables.length, 'mentionables,', imageMentionables.length, 'images') + if (imageMentionables.length > 0) { + console.log('[ImageFlow] Image names:', imageMentionables.map(m => m.type === 'image' ? m.name : '').join(', ')) + } handleUserMessageSubmit({ inputChatMessages: [...chatMessages, { ...inputMessage, content }], useVaultSearch, diff --git a/src/components/chat-view/chat-input/ChatUserInput.tsx b/src/components/chat-view/chat-input/ChatUserInput.tsx index 4b07f691..581b446c 100644 --- a/src/components/chat-view/chat-input/ChatUserInput.tsx +++ b/src/components/chat-view/chat-input/ChatUserInput.tsx @@ -148,6 +148,7 @@ const ChatUserInput = forwardRef( const handleCreateImageMentionables = useCallback( (mentionableImages: MentionableImage[]) => { + console.log('[ImageFlow] handleCreateImageMentionables called with', mentionableImages.length, 'images') const newMentionableImages = mentionableImages.filter( (m) => !mentionables.some( @@ -157,6 +158,7 @@ const ChatUserInput = forwardRef( ), ) if (newMentionableImages.length === 0) return + console.log('[ImageFlow] Adding', newMentionableImages.length, 'new image mentionables, total mentionables will be:', mentionables.length + newMentionableImages.length) setMentionables([...mentionables, ...newMentionableImages]) setDisplayedMentionableKey( getMentionableKey( @@ -200,6 +202,10 @@ const ChatUserInput = forwardRef( if (content) { // Blur input to hide mobile keyboard after submit contentEditableRef.current?.blur() + // iOS sometimes ignores blur on contenteditable + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } onSubmit(content, options.useVaultSearch) } } diff --git a/src/core/backend/BackendProvider.ts b/src/core/backend/BackendProvider.ts index 7789d683..16c876de 100644 --- a/src/core/backend/BackendProvider.ts +++ b/src/core/backend/BackendProvider.ts @@ -64,9 +64,8 @@ export class BackendProvider extends BaseLLMProvider { throw new Error('Backend not connected'); } - // Convert request messages to a prompt string - // The backend will handle the full conversation context - const prompt = this.convertRequestToPrompt(request); + // Extract prompt text and any images from the request + const { prompt, images } = this.extractPromptAndImages(request); // Extract system prompt from messages if present const systemMessage = request.messages.find((m) => m.role === 'system'); @@ -83,7 +82,8 @@ export class BackendProvider extends BaseLLMProvider { undefined, options, systemPrompt, - modelToUse + modelToUse, + images ); return generator; @@ -155,7 +155,8 @@ export class BackendProvider extends BaseLLMProvider { context?: { currentFile?: string; selection?: string }, options?: LLMOptions, systemPrompt?: string, - model?: string + model?: string, + images?: Array<{ mimeType: string; base64Data: string }> ): AsyncGenerator { // State for accumulating responses const toolCalls: Map< @@ -462,7 +463,8 @@ export class BackendProvider extends BaseLLMProvider { }, context, systemPrompt, - model + model, + images ); // Yield chunks as they arrive @@ -529,18 +531,54 @@ export class BackendProvider extends BaseLLMProvider { } /** - * Convert request messages to a simple prompt string - * The backend's agent will handle the full conversation context + * Extract prompt text and images from request messages. + * Images are sent separately as multimodal content blocks. */ - private convertRequestToPrompt( + private extractPromptAndImages( request: LLMRequestStreaming | LLMRequestNonStreaming - ): string { - // For now, we'll send the entire message history as a JSON string - // The backend can parse this and use it with the agent - return JSON.stringify({ - messages: request.messages, + ): { prompt: string; images: Array<{ mimeType: string; base64Data: string }> } { + const images: Array<{ mimeType: string; base64Data: string }> = []; + console.log('[ImageFlow] extractPromptAndImages: scanning', request.messages.length, 'messages for images'); + + // Strip image content parts from messages, collect them separately + const messagesWithoutImages = request.messages.map((msg) => { + if (msg.role === 'user' && Array.isArray(msg.content)) { + const textParts: Array<{ type: 'text'; text: string }> = []; + for (const part of msg.content) { + if (part.type === 'image_url') { + // Parse data URL: "data:image/png;base64,..." + const dataUrl = part.image_url.url; + const match = dataUrl.match( + /^data:([^;]+);base64,(.+)$/ + ); + if (match) { + images.push({ + mimeType: match[1], + base64Data: match[2], + }); + } + } else { + textParts.push(part); + } + } + return { + ...msg, + content: + textParts.length === 1 + ? textParts[0].text + : textParts, + }; + } + return msg; + }); + + const prompt = JSON.stringify({ + messages: messagesWithoutImages, tools: request.tools, tool_choice: request.tool_choice, }); + + console.log('[ImageFlow] extractPromptAndImages: found', images.length, 'images, types:', images.map(i => i.mimeType).join(', ')); + return { prompt, images }; } } diff --git a/src/core/backend/WebSocketClient.ts b/src/core/backend/WebSocketClient.ts index 1dbaf047..67aee19d 100644 --- a/src/core/backend/WebSocketClient.ts +++ b/src/core/backend/WebSocketClient.ts @@ -175,7 +175,8 @@ export class WebSocketClient { handlers: StreamingHandlers, context?: AgentContext, systemPrompt?: string, - model?: string + model?: string, + images?: Array<{ mimeType: string; base64Data: string }> ): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error('Not connected to backend'); @@ -191,6 +192,7 @@ export class WebSocketClient { context, systemPrompt, model, + ...(images && images.length > 0 ? { images } : {}), }; this.send(message); diff --git a/src/core/backend/protocol.ts b/src/core/backend/protocol.ts index 0729e33b..b6e2d460 100644 --- a/src/core/backend/protocol.ts +++ b/src/core/backend/protocol.ts @@ -47,6 +47,8 @@ export interface PromptMessage { systemPrompt?: string; /** Model to use for this request (e.g., 'claude-opus-4-5-20250514') */ model?: string; + /** Images to include as multimodal content */ + images?: Array<{ mimeType: string; base64Data: string }>; } /** Response to an RPC request from server */ diff --git a/src/utils/chat/promptGenerator.ts b/src/utils/chat/promptGenerator.ts index 0e0ffe13..b0e37413 100644 --- a/src/utils/chat/promptGenerator.ts +++ b/src/utils/chat/promptGenerator.ts @@ -100,9 +100,12 @@ export class PromptGenerator { ? await this.getCurrentFileMessage(currentFile) : undefined + const memoryMessage = await this.getMemoryMessage() + const requestMessages: RequestMessage[] = [ systemMessage, ...(customInstructionMessage ? [customInstructionMessage] : []), + ...(memoryMessage ? [memoryMessage] : []), ...(currentFileMessage ? [currentFileMessage] : []), ...this.getChatHistoryMessages({ messages: compiledMessages }), ...(shouldUseRAG && this.getModelPromptLevel() == PromptLevel.Default @@ -268,6 +271,59 @@ ${message.annotations const query = editorStateToPlainText(message.content) let similaritySearchResults = undefined + // Backend provider: skip file reading and RAG — the agent has vault_read + if (this.isBackendProvider()) { + const imgCount = message.mentionables.filter(m => m.type === 'image').length + console.log('[ImageFlow] compileUserMessagePrompt (backend): mentionables =', message.mentionables.length, ', images =', imgCount) + const files = message.mentionables + .filter((m): m is MentionableFile => m.type === 'file') + .map((m) => m.file) + const folders = message.mentionables + .filter((m): m is MentionableFolder => m.type === 'folder') + .map((m) => m.folder) + const nestedFiles = folders.flatMap((folder) => + getNestedFiles(folder, this.app.vault), + ) + const allFiles = [...files, ...nestedFiles] + + const filePrompt = + allFiles.length > 0 + ? `## Referenced Files\nThe user mentioned these files. Use vault_read to read them if needed:\n${allFiles.map((f) => `- ${f.path}`).join('\n')}\n\n` + : '' + + const blocks = message.mentionables.filter( + (m): m is MentionableBlock => m.type === 'block', + ) + const blockPrompt = blocks + .map(({ file, content }) => { + return `\`\`\`${file.path}\n${content}\n\`\`\`\n` + }) + .join('') + + const imageDataUrls = message.mentionables + .filter((m): m is MentionableImage => m.type === 'image') + .map(({ data }) => data) + + onQueryProgressChange?.({ type: 'idle' }) + + return { + promptContent: [ + ...imageDataUrls.map( + (data): ContentPart => ({ + type: 'image_url', + image_url: { url: data }, + }), + ), + { + type: 'text', + text: `${filePrompt}${blockPrompt}\n\n${query}\n\n`, + }, + ], + shouldUseRAG: false, + } + } + + // Non-backend providers: original file reading + RAG behavior useVaultSearch = // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing useVaultSearch || @@ -499,9 +555,46 @@ ${customInstruction} } } + private async getMemoryMessage(): Promise { + try { + const memoryFile = this.app.vault.getAbstractFileByPath('.claude/memory.md') + if (!memoryFile || !(memoryFile instanceof TFile)) { + return null + } + const content = await readTFileContent(memoryFile, this.app.vault) + if (!content?.trim()) { + return null + } + return { + role: 'user', + content: `Here is persistent memory from past conversations. Use it for context continuity — there's no need to explicitly acknowledge it: + +${content} +`, + } + } catch { + return null + } + } + + private static readonly TEXT_EXTENSIONS = new Set([ + 'md', 'txt', 'markdown', 'csv', 'json', 'yaml', 'yml', 'xml', 'html', + 'htm', 'css', 'js', 'ts', 'jsx', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', + 'h', 'hpp', 'rs', 'go', 'sh', 'bash', 'zsh', 'sql', 'r', 'lua', 'swift', + 'kt', 'scala', 'toml', 'ini', 'cfg', 'conf', 'env', 'log', 'tex', + 'bib', 'org', 'rst', 'adoc', 'mdx', 'svelte', 'vue', 'php', + ]) + + private isTextFile(file: TFile): boolean { + return PromptGenerator.TEXT_EXTENSIONS.has(file.extension.toLowerCase()) + } + private async getCurrentFileMessage( currentFile: TFile, - ): Promise { + ): Promise { + if (!this.isTextFile(currentFile)) { + return undefined + } const fileContent = await readTFileContent(currentFile, this.app.vault) return { role: 'user', @@ -562,6 +655,13 @@ ${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}` return htmlToMarkdown(response.text) } + public isBackendProvider(): boolean { + const chatModel = this.settings.chatModels.find( + (model) => model.id === this.settings.chatModelId, + ) + return chatModel?.providerType === 'backend' + } + private getModelPromptLevel(): PromptLevel { const chatModel = this.settings.chatModels.find( (model) => model.id === this.settings.chatModelId,