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/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/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..94d0c46c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,22 @@ +# 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 +AUTH_TOKEN=your-secret-token-here + +# Server port (default: 3001) +PORT=3001 + +# 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/.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..b953b75e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,48 @@ +FROM node:22-slim + +# 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 ./ + +# Install all dependencies (need devDeps for build) +RUN npm ci + +# Copy source files +COPY src ./src +COPY tsconfig.json ./ + +# Build TypeScript +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 +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 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + 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/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..cd2f93e3 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2186 @@ +{ + "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/claude-agent-sdk": "^0.2.38", + "dotenv": "^16.4.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", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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/@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/@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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "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", + "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", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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/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", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "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", + "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==", + "dev": true, + "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", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "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==", + "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/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" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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" + } + }, + "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==", + "dev": true, + "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/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==", + "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" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "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/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==", + "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/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" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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/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", + "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==", + "dev": true, + "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/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" + } + }, + "node_modules/fresh": { + "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" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "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==", + "dev": true, + "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==", + "dev": true, + "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/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" + }, + "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==", + "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" + } + }, + "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==", + "dev": true, + "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==", + "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" + } + }, + "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==", + "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" + } + }, + "node_modules/media-typer": { + "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" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "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/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" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "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==", + "dev": true, + "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==", + "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", + "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/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", + "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==", + "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" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "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/type-is": { + "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", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "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" + } + }, + "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/vary": { + "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/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" + }, + "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==", + "dev": true, + "license": "ISC" + }, + "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": "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" + } + }, + "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==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..7e1a0344 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,35 @@ +{ + "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", + "dev:mock": "MOCK_MODE=true tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "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/claude-agent-sdk": "^0.2.38", + "dotenv": "^16.4.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", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20.0.0" + } +} 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/backend/src/agent.ts b/backend/src/agent.ts new file mode 100644 index 00000000..0a496377 --- /dev/null +++ b/backend/src/agent.ts @@ -0,0 +1,441 @@ +/** + * Claude Agent Integration + * + * Implements the agent using the Claude Agent SDK's query() function, + * which handles the tool execution loop automatically. + */ + +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createVaultMcpServer } from './vault-tools.js'; +import { logger } from './utils.js'; +import type { + VaultBridge, + AgentContext, + AgentEvent, +} from './protocol.js'; + +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 +- 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) +- 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 +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 +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 + +## 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 +- 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" +- **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 + +## Response Style +- 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 +- 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. + +## 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; + 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 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) { + 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 DEFAULT_MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-6'; +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 + +/** + * 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 + */ +export async function* runAgent( + prompt: string, + bridge: VaultBridge, + context?: AgentContext, + signal?: AbortSignal, + customSystemPrompt?: string, + model?: string, + images?: Array<{ mimeType: string; base64Data: string }> +): AsyncGenerator { + const selectedModel = model || DEFAULT_MODEL; + logger.info(`Using model: ${selectedModel}`); + + // Shared event queue — tool handlers push tool_end events here + const eventQueue: AgentEvent[] = []; + + // 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(); + 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?.trim()) { + systemPrompt = `${customSystemPrompt.trim()}\n\n${systemPrompt}`; + } + + // Build context-aware prompt + let fullPrompt = prompt; + if (context?.currentFile) { + fullPrompt = `[Currently viewing: ${context.currentFile}]\n\n${fullPrompt}`; + } + if (context?.selection) { + fullPrompt = `[Selected text: "${context.selection}"]\n\n${fullPrompt}`; + } + + // 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); + } + + // Build allowed tools list — all MCP tools + web search + const allowedTools: string[] = Object.keys(mcpServers).map(name => `mcp__${name}__*`); + 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: userMessage, + parent_tool_use_id: null, + session_id: '', + }; + } + + // 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[] = []; + + // 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()!; + // 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: '' }; + } + } + + // 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?.(); + + try { + const queryStream = query({ + prompt: singlePrompt(), + options: { + model: selectedModel, + systemPrompt, + mcpServers: mcpServers as Record, + allowedTools, + maxTurns: MAX_TURNS, + abortController, + permissionMode: 'bypassPermissions' as const, + includePartialMessages: true, + thinking: { type: 'adaptive' }, + stderr: (data: string) => { + heartbeat(); // stderr output = activity + logger.warn(`CLI stderr: ${data.trimEnd()}`); + }, + }, + }); + + 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': { + // 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 + 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') { + logger.info(`[Thinking] delta: ${(delta.thinking || '').substring(0, 80)}...`); + yield { type: 'thinking', text: delta.thinking }; + } + } + break; + } + + case 'assistant': { + // Close any pending tools from the previous turn + yield* closePendingTools(); + + // 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); + pendingTools.push(name); + yield { + type: 'tool_start', + name, + input: block.input as Record, + }; + } + } + break; + } + + case 'result': { + // Close any remaining pending tools + yield* drainToolEvents(); + yield* closePendingTools(); + + if (message.subtype === 'success') { + 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); + yield { + type: 'error', + code: message.subtype, + message: errors?.join(', ') || 'Agent SDK error', + }; + } + break; + } + + default: + break; + } + } + } 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 : ''; + + // 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); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 00000000..6d86b339 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,81 @@ +/** + * 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'; + +const MOCK_MODE = process.env.MOCK_MODE === 'true'; + +// Verify required environment variables +function checkEnv() { + // In mock mode, we don't need credentials + if (!MOCK_MODE) { + 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) + 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)'}`); + if (!MOCK_MODE) { + logger.info(` CLAUDE_MODEL: ${process.env.CLAUDE_MODEL || 'claude-opus-4-6'}`); + } + 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/mock-agent.ts b/backend/src/mock-agent.ts new file mode 100644 index 00000000..4ae70c3b --- /dev/null +++ b/backend/src/mock-agent.ts @@ -0,0 +1,211 @@ +/** + * 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 './vault-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, + _customSystemPrompt?: string, + _model?: string +): 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/protocol.ts b/backend/src/protocol.ts new file mode 100644 index 00000000..34bca8f8 --- /dev/null +++ b/backend/src/protocol.ts @@ -0,0 +1,225 @@ +/** + * 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; + /** 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; + /** Images to include as multimodal content */ + images?: Array<{ mimeType: string; base64Data: string }>; +} + +/** 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; + +// ============================================================================ +// 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 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 new file mode 100644 index 00000000..b1070300 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,387 @@ +/** + * WebSocket Server + * + * Handles connections from the Obsidian plugin, routes messages, + * and manages the bidirectional RPC protocol. + */ + +import { WebSocketServer, WebSocket } from 'ws'; +import { createServer, type Server } from 'http'; +import { randomUUID } from 'crypto'; +import type { IncomingMessage, ServerResponse } 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, + PromptMessage, + RpcResponseMessage, + CancelMessage, + VaultBridge, + SearchResult, + FileInfo, + GrepResult, +} 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 lastActivity = Date.now(); + + constructor(private ws: WebSocket) { + 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)); + } + + /** + * 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 { + 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; + } + 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); + let completeSent = false; + + logger.info(`Processing prompt request ${msg.id}`); + + try { + // Create vault bridge that sends RPC requests to plugin + const vaultBridge = this.createVaultBridge(); + + // 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, + abortController.signal, + msg.systemPrompt, + msg.model, + msg.images + )) { + 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': + completeSent = true; + 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 { + // 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); + } + } + + 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 = 60000; // 60 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 }); + }, + 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 }); + }, + }; + } + + 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 HTTP + WebSocket server + */ +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) { + 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); + }); + + httpServer.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); + }); + + return httpServer; +} 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/src/vault-tools.ts b/backend/src/vault-tools.ts new file mode 100644 index 00000000..aa3dc495 --- /dev/null +++ b/backend/src/vault-tools.ts @@ -0,0 +1,308 @@ +/** + * 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[], heartbeat?: () => void) { + 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) => { + 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 }] }; + } + ), + + 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) => { + 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 }); + 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) => { + 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 }); + 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) => { + heartbeat?.(); + 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) => { + heartbeat?.(); + 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) => { + heartbeat?.(); + 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) => { + heartbeat?.(); + 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) => { + 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 }); + 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) => { + heartbeat?.(); + 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 }] }; + } + ), + ], + }); +} 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(); 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"] +} 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/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/railway.toml b/railway.toml new file mode 100644 index 00000000..09d9bacb --- /dev/null +++ b/railway.toml @@ -0,0 +1,11 @@ +[build] +builder = "dockerfile" +dockerfilePath = "backend/Dockerfile" +buildContext = "backend" +watchPatterns = ["backend/**"] + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 3 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/ActivityAccordion.tsx b/src/components/chat-view/ActivityAccordion.tsx new file mode 100644 index 00000000..10c3dccd --- /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..aed92c32 --- /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..f029fc2e 100644 --- a/src/components/chat-view/AssistantToolMessageGroupItem.tsx +++ b/src/components/chat-view/AssistantToolMessageGroupItem.tsx @@ -1,20 +1,70 @@ +import React, { 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 InterspersedContent from './InterspersedContent' 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,40 +74,263 @@ 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 + + const activityId = `tool-${toolCall.request.id}` + if (seenIds.has(activityId)) continue + seenIds.add(activityId) + + const cleanName = toolName.replace('backend__', '') + 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 { + 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) + } + } + } + } + + // 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]) + + // 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 (
- {messages.map((message) => - message.role === 'assistant' ? ( - message.reasoning || message.annotations || message.content ? ( -
- {message.reasoning && ( - - )} - {message.annotations && ( - { + 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) => ( + + ))}
- ) : null - ) : ( -
- -
- ), + )} + + {messages.map((message) => { + if (message.role === 'assistant') { + const displayContent = getDisplayContent( + message.content || '', + allActivities.length > 0, + ) + + 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/Chat.tsx b/src/components/chat-view/Chat.tsx index e07b248a..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 @@ -676,6 +693,7 @@ const Chat = forwardRef((props, ref) => { )} conversationId={currentConversationId} isApplying={applyMutation.isPending} + isStreaming={submitChatMutation.isPending && index === groupedChatMessages.length - 1} onApply={handleApply} onToolMessageUpdate={handleToolMessageUpdate} /> @@ -711,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/EditDiffBlock.tsx b/src/components/chat-view/EditDiffBlock.tsx new file mode 100644 index 00000000..d7cca0b3 --- /dev/null +++ b/src/components/chat-view/EditDiffBlock.tsx @@ -0,0 +1,233 @@ +/** + * 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, 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 + 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 [isPreviewMode, setIsPreviewMode] = useState(true) + 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 + + 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}{' '} + { + e.stopPropagation() + handleOpenFile() + }} + title={activity.filePath} + > + {displayName} + + + + {additions > 0 && +{additions}} + {deletions > 0 && -{deletions}} + +
+ + {!reverted && ( + + )} + {reverted && ( + Reverted + )} +
+
+ + {isOpen && activity.diff && ( +
+ {isPreviewMode ? ( +
+ +
+ ) : ( + + )} +
+ )} +
+ ) +}) + +export default EditDiffBlock 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/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/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/ChatUserInput.tsx b/src/components/chat-view/chat-input/ChatUserInput.tsx index 24dc5963..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( @@ -197,7 +199,15 @@ 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() + // iOS sometimes ignores blur on contenteditable + if (document.activeElement instanceof HTMLElement) { + document.activeElement.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/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, + ) + } + /> + )} + + ))} + + + { + await setSettings({ + ...settings, + externalResourceDir: value, + }) + }} + /> +
      ) } diff --git a/src/components/settings/sections/ProvidersSection.tsx b/src/components/settings/sections/ProvidersSection.tsx index 879e42b7..6bf21626 100644 --- a/src/components/settings/sections/ProvidersSection.tsx +++ b/src/components/settings/sections/ProvidersSection.tsx @@ -110,40 +110,53 @@ export function ProvidersSection({ app, plugin }: ProvidersSectionProps) { - {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/BackendProvider.ts b/src/core/backend/BackendProvider.ts new file mode 100644 index 00000000..16c876de --- /dev/null +++ b/src/core/backend/BackendProvider.ts @@ -0,0 +1,584 @@ +/** + * Backend Provider + * + * LLM provider implementation that uses the backend WebSocket service + * instead of making direct API calls. + */ + +import { BaseLLMProvider } from '../llm/base'; +import { LLMRateLimitExceededException } from '../llm/exception'; +import type { ChatModel } from '../../types/chat-model.types'; +import type { ActivityEvent, ActivityType, ContentBlock } from '../../types/chat'; +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 { 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, + 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'); + } + + // 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'); + const systemPrompt = + systemMessage && typeof systemMessage.content === 'string' + ? systemMessage.content + : 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, + modelToUse, + images + ); + + 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, + images?: Array<{ mimeType: string; base64Data: string }> + ): AsyncGenerator { + // State for accumulating responses + const toolCalls: Map< + number, + { + id: string; + name: string; + arguments: string; + result?: string; + activityId: string; + } + > = new Map(); + let isComplete = 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[] = []; + 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); + } + }; + + // 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', + model: 'backend', + choices: [ + { + delta: { content: text }, + finish_reason: null, + }, + ], + }; + enqueueChunk(chunk); + }, + + 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 + arguments: JSON.stringify(input), + activityId, + }); + + // Send tool call delta to show in UI (legacy) + const toolDelta: ToolCallDelta = { + index, + id: toolId, + type: 'function', + function: { + name: `backend__${name}`, + arguments: JSON.stringify(input), + }, + }; + + // 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, + }; + + // 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', + model: 'backend', + choices: [ + { + delta: { + tool_calls: [toolDelta], + activity, + }, + finish_reason: null, + }, + ], + }); + }, + + onToolEnd: (name: string, result: string) => { + // Store result and send completed activity + for (const tool of Array.from(toolCalls.values())) { + if (tool.name === `backend__${name}` && !tool.result) { + tool.result = result; + + // 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) { + // 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 + 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, + activity, + }, + finish_reason: null, + }, + ], + }; + enqueueChunk(chunk); + }, + + onComplete: (result: string) => { + // Flush any pending content blocks for interspersed layout + flushTextBlock(); + flushActivityGroup(); + + // 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', + 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}` + ); + errorState.error = { code, message }; + 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, + images + ); + + // 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, racing against abort signal + const chunk = await new Promise( + (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 } + ); + } + } + ); + + // Null means aborted + if (chunk === null) { + this.wsClient.cancelRequest(requestId); + break; + } + yield chunk; + } + } + + 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'); + } + } + + /** + * Extract prompt text and images from request messages. + * Images are sent separately as multimodal content blocks. + */ + private extractPromptAndImages( + request: LLMRequestStreaming | LLMRequestNonStreaming + ): { 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/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/EditHistory.ts b/src/core/backend/EditHistory.ts new file mode 100644 index 00000000..f2451f0d --- /dev/null +++ b/src/core/backend/EditHistory.ts @@ -0,0 +1,166 @@ +/** + * 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)) { + 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; + } 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 new file mode 100644 index 00000000..f3e2de30 --- /dev/null +++ b/src/core/backend/VaultRpcHandler.ts @@ -0,0 +1,468 @@ +/** + * 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'; +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, + activityId?: string + ): 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, + activityId + ); + case 'vault_edit': + return this.vaultEdit( + params.path as string, + params.old_string as string, + params.new_string as string, + activityId + ); + 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, + activityId + ); + case 'vault_delete': + return this.vaultDelete(params.path as string, activityId); + 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, + activityId?: string + ): Promise<{ success: boolean }> { + const existingFile = this.app.vault.getAbstractFileByPath(path); + + if (existingFile instanceof TFile) { + // 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) { + 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, + activityId?: 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.` + ); + } + + // 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); + + 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, + activityId?: string + ): Promise<{ success: boolean }> { + const file = this.app.vault.getAbstractFileByPath(oldPath); + + if (!file) { + 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) { + 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, + 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); + + 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/WebSocketClient.ts b/src/core/backend/WebSocketClient.ts new file mode 100644 index 00000000..67aee19d --- /dev/null +++ b/src/core/backend/WebSocketClient.ts @@ -0,0 +1,472 @@ +/** + * 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; + private pendingActivityIds: { + toolName: string; + filePath: string; + activityId: string; + }[] = []; + + /** + * 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, + images?: Array<{ mimeType: string; base64Data: 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, + ...(images && images.length > 0 ? { images } : {}), + }; + + 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, + }); + } + + /** + * 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); + } + } + + /** + * 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 + */ + 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); + // 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': { + 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(); + + // 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(); + } + } + + 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' }); + } + }, 25000); // Ping every 25 seconds (server timeout is 90s) + } + + 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/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/protocol.ts b/src/core/backend/protocol.ts new file mode 100644 index 00000000..b6e2d460 --- /dev/null +++ b/src/core/backend/protocol.ts @@ -0,0 +1,161 @@ +/** + * 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; + /** 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; + /** Images to include as multimodal content */ + images?: Array<{ mimeType: string; base64Data: string }>; +} + +/** 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..cd46fe0d --- /dev/null +++ b/src/core/backend/tool-result-formatter.ts @@ -0,0 +1,315 @@ +/** + * 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 + +/** + * 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, // Unused, kept for backwards compatibility + 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**${title}:** ${fileRefs.join(', ')}`; + } + + // For many files, show count with expandable list + const visibleRefs = fileRefs.slice(0, maxVisible); + const hiddenCount = count - maxVisible; + + return `\n**${title}:** ${visibleRefs.join(', ')} *+${hiddenCount} more*`; +} + +export function formatToolResult( + toolName: string, + result: string, + toolArguments: string +): string { + let formattedResult = ''; + + 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) { + const filepath = match[1].trim(); + const displayName = filepath.split('/').pop() || filepath; + fileRefs.push(`[[${filepath}|${displayName}]]`); + } + } + + formattedResult = formatCollapsibleList('Found', '', fileRefs); + } else if (toolName === 'vault_list') { + const lines = result.split('\n'); + const folders: string[] = []; + const files: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('- ')) continue; + + // 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('[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); + } + } + } + + 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') { + 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') { + try { + const args = JSON.parse(toolArguments); + if (args.path) { + const displayName = args.path.split('/').pop() || args.path; + formattedResult = `\n**Created:** [[${args.path}|${displayName}]]`; + } + } catch (e) { + // Silently ignore parse errors + } + } else if (toolName === 'vault_edit') { + try { + const args = JSON.parse(toolArguments); + if (args.path) { + const displayName = args.path.split('/').pop() || args.path; + 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') { + 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) { + const filepath = match[1].trim(); + if (!seenFiles.has(filepath)) { + seenFiles.add(filepath); + const displayName = filepath.split('/').pop() || filepath; + fileRefs.push(`[[${filepath}|${displayName}]]`); + } + } + } + + formattedResult = formatCollapsibleList('Grep matches', '', 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) { + const filepath = match[1].trim(); + const displayName = filepath.split('/').pop() || filepath; + fileRefs.push(`[[${filepath}|${displayName}]]`); + } + } + + 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}]]`; + } + } catch (e) { + // Silently ignore parse errors + } + } + + return formattedResult; +} 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/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/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..d3581b7c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,11 @@ 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 { initEditHistory } from './core/backend/EditHistory' +import type { RpcRequestMessage } from './core/backend/protocol' export default class SmartComposerPlugin extends Plugin { settings: SmartComposerSettings @@ -25,6 +30,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 +39,46 @@ 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) + 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 + + try { + // 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, + activityId, + ) + + 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 +178,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 @@ -154,8 +201,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) { @@ -334,8 +382,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') { + 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/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({ 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-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/chat.ts b/src/types/chat.ts index a682bbe8..f8c364ca 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -8,6 +8,68 @@ 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 +} + +/** + * 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 @@ -21,9 +83,11 @@ 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[] + activities?: ActivityEvent[] // Cursor-style activity tracking id: string metadata?: { usage?: ResponseUsage @@ -62,9 +126,11 @@ 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[] + 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..e794c2f5 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, ContentBlock } from '../chat' + export type LLMResponseBase = { id: string created?: number @@ -47,6 +49,8 @@ type StreamingChoice = { role?: string annotations?: Annotation[] tool_calls?: ToolCallDelta[] + activity?: ActivityEvent // For Cursor-style activity streaming + contentBlock?: ContentBlock // For interspersed layout } error?: Error } 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/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, diff --git a/src/utils/chat/responseGenerator.ts b/src/utils/chat/responseGenerator.ts index 6c552dbf..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 { 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 { @@ -248,6 +248,8 @@ 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 contentBlock = chunk.choices[0]?.delta?.contentBlock const updatedToolCalls = toolCalls ? this.mergeToolCallDeltas(toolCalls, responseToolCalls) @@ -292,6 +294,12 @@ export class ResponseGenerator { message.annotations, annotations, ), + 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, @@ -376,4 +384,75 @@ 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 + */ + 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 b0b769c9..8a5aa0c9 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; @@ -929,9 +950,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 +969,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; @@ -1866,3 +1925,346 @@ 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); + } +} + +/* 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; + 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-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); + 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; +}