diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc34b4d54..76090d6f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,6 +45,8 @@ jobs: integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }} integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }} integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }} + integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }} + integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }} dev: ${{ steps.filter.outputs.dev }} ci: ${{ steps.filter.outputs.ci }} # Secrets are available for internal PRs, pull_request_review, and workflow_dispatch. @@ -117,6 +119,10 @@ jobs: - 'hindsight-integrations/hermes/**' integrations-llamaindex: - 'hindsight-integrations/llamaindex/**' + integrations-opencode: + - 'hindsight-integrations/opencode/**' + integrations-cursor: + - 'hindsight-integrations/cursor/**' dev: - 'hindsight-dev/**' ci: @@ -237,6 +243,32 @@ jobs: working-directory: ./hindsight-integrations/claude-code run: python -m pytest tests/ -v + test-cursor-integration: + needs: [detect-changes] + if: >- + github.event_name != 'pull_request_review' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-cursor == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install pytest + run: pip install pytest + + - name: Run tests + working-directory: ./hindsight-integrations/cursor + run: python -m pytest tests/ -v + test-codex-integration: needs: [detect-changes] if: >- @@ -326,6 +358,37 @@ jobs: working-directory: ./hindsight-integrations/ai-sdk run: npm run test:deno + build-opencode-integration: + needs: [detect-changes] + if: >- + github.event_name != 'pull_request_review' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-opencode == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Install dependencies + working-directory: ./hindsight-integrations/opencode + run: npm ci + + - name: Run tests + working-directory: ./hindsight-integrations/opencode + run: npm test + + - name: Build + working-directory: ./hindsight-integrations/opencode + run: npm run build + build-chat-integration: needs: [detect-changes] if: >- @@ -2424,9 +2487,11 @@ jobs: - build-typescript-client - build-openclaw-integration - test-claude-code-integration + - test-cursor-integration - test-codex-integration - build-ai-sdk-integration - test-ai-sdk-integration-deno + - build-opencode-integration - build-chat-integration - build-control-plane - build-docs diff --git a/hindsight-docs/blog/2026-04-03-cursor-persistent-memory.md b/hindsight-docs/blog/2026-04-03-cursor-persistent-memory.md new file mode 100644 index 000000000..43c92f132 --- /dev/null +++ b/hindsight-docs/blog/2026-04-03-cursor-persistent-memory.md @@ -0,0 +1,123 @@ +--- +title: "Giving Cursor a Long-Term Memory" +description: Cursor resets its memory every session. Learn how to add persistent cross-session memory using the Hindsight plugin for Cursor. Zero dependencies, automatic recall and retain. +authors: [DK09876] +date: 2026-04-03T12:00 +tags: [cursor, integrations, memory, plugin, coding-agents] +image: /img/blog/cursor-persistent-memory.png +hide_table_of_contents: true +--- + +![Giving Cursor a Long-Term Memory](/img/blog/cursor-persistent-memory.png) + +Cursor 3 introduced a plugin system with hooks, skills, and rules. It's a powerful architecture for extending what the agent can do. But one thing Cursor still doesn't have out of the box is persistent memory across sessions. Every new chat starts from scratch. + +If you've spent three turns explaining your project's architecture, your team's naming conventions, or that you prefer functional patterns over classes, Cursor forgets all of it the moment the session ends. + +The Hindsight plugin for Cursor fixes this. It hooks into the `beforeSubmitPrompt` event to recall relevant memories before every prompt, and the `stop` event to retain conversation transcripts after every task. No manual effort, no copy-pasting context, no dependencies to install. + + + +## The Problem: Session-Scoped Memory + +Cursor's built-in context system is designed around the current session. You get your codebase, your open files, and whatever you type. That's great for single-session tasks, but it falls apart for anything that spans multiple sessions: + +- **Repeated explanations.** You tell Cursor your API uses snake_case, your frontend uses camelCase, and tests go in `__tests__/` directories. Next session? You explain it again. +- **Lost decisions.** You and Cursor agree on an architecture — event-driven with a message bus. Two days later, you start a new session and Cursor suggests REST endpoints. +- **No user model.** Cursor doesn't know that you're a senior engineer who doesn't need basic explanations, or that you prefer TypeScript over JavaScript, or that your team uses Vitest instead of Jest. + +These aren't edge cases. They're the default experience for anyone who uses Cursor across more than a few sessions. + +## How the Plugin Works + +The Hindsight plugin uses two of Cursor's hook events to create a transparent memory loop: + +### Auto-Recall (beforeSubmitPrompt) + +Every time you send a prompt, the plugin fires before Cursor processes it: + +1. Composes a query from your prompt (optionally including prior turns for context) +2. Calls Hindsight's recall API with multi-strategy retrieval (semantic search, BM25, graph traversal, temporal filtering) +3. Formats the results into a `` block +4. Injects it as `additionalContext` — the agent sees it, you don't + +The memories appear in the agent's context window but not in your chat transcript. Cursor uses them to inform its response without cluttering your conversation. + +### Auto-Retain (stop) + +After Cursor completes a task, the plugin fires again: + +1. Reads the conversation transcript from Cursor's JSONL file +2. Strips any injected memory tags (preventing feedback loops) +3. Applies chunked or full-session retention based on your config +4. POSTs the transcript to Hindsight for fact extraction and storage + +Hindsight extracts structured facts — preferences, decisions, project details — and builds a knowledge graph over time. The next session, those facts are available for recall. + +### On-Demand Recall + +The plugin also registers a `hindsight-recall` skill. If you want to explicitly search your memory mid-conversation, use `/hindsight-recall` with a query. This is useful when auto-recall didn't surface what you need, or when you want to search for something specific. + +## Setup in 60 Seconds + +**Step 1:** Install the plugin into your project: + +```bash +cp -r hindsight-integrations/cursor /path/to/your-project/.cursor-plugin/hindsight-memory +``` + +**Step 2:** Set an LLM provider (for the local Hindsight daemon): + +```bash +export OPENAI_API_KEY="sk-your-key" +# or +export ANTHROPIC_API_KEY="your-key" +``` + +Or connect to an existing Hindsight server: + +```bash +mkdir -p ~/.hindsight +echo '{"hindsightApiUrl": "https://your-server.com"}' > ~/.hindsight/cursor.json +``` + +**Step 3:** Open Cursor. The plugin activates automatically. + +That's it. No pip install, no npm install, no build step. The plugin is pure Python stdlib. + +## Per-Project Memory Isolation + +By default, all Cursor sessions share a single memory bank (`cursor`). For teams or multi-project workflows, you can isolate memory per project: + +```json +{ + "dynamicBankId": true, + "dynamicBankGranularity": ["agent", "project"] +} +``` + +This derives the bank ID from the working directory. Your React project and your Go API get separate memories that don't cross-contaminate. + +Available granularity fields: `agent`, `project`, `session`, `channel`, `user`. Combine them to match your workflow. + +## Alternative: MCP Integration + +If you'd rather use Cursor's native MCP support instead of the plugin system, Hindsight works there too. Add to `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "hindsight": { + "url": "http://localhost:8888/mcp/" + } + } +} +``` + +This gives you explicit `retain`, `recall`, and `reflect` tools. The plugin approach is more automatic (hooks fire without you asking); the MCP approach gives you more control. + +## What's Next + +The Cursor plugin is open source and available in the [Hindsight repository](https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/cursor). Full configuration reference is in the [integration docs](/sdks/integrations/cursor). + +Works with [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) or self-hosted via `hindsight-embed`. diff --git a/hindsight-docs/docs-integrations/cursor.md b/hindsight-docs/docs-integrations/cursor.md new file mode 100644 index 000000000..80598aef5 --- /dev/null +++ b/hindsight-docs/docs-integrations/cursor.md @@ -0,0 +1,192 @@ +--- +sidebar_position: 6 +title: "Cursor Persistent Memory with Hindsight | Integration" +description: "Add long-term memory to Cursor with Hindsight. Automatically captures conversations and recalls relevant context across sessions using Cursor's plugin architecture." +--- + +# Cursor + +Biomimetic long-term memory for [Cursor](https://cursor.com) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations and intelligently recalls relevant context — adapted to Cursor's hook-based plugin architecture. + +## Quick Start + +```bash +# 1. Copy the plugin into your project +cp -r hindsight-integrations/cursor /path/to/your-project/.cursor-plugin/hindsight-memory + +# 2. Configure your LLM provider for memory extraction +# Option A: OpenAI (auto-detected) +export OPENAI_API_KEY="sk-your-key" + +# Option B: Anthropic (auto-detected) +export ANTHROPIC_API_KEY="your-key" + +# Option C: Connect to an external Hindsight server +mkdir -p ~/.hindsight +echo '{"hindsightApiUrl": "https://your-hindsight-server.com"}' > ~/.hindsight/cursor.json + +# 3. Open Cursor — the plugin activates automatically +``` + +That's it! The plugin will automatically start capturing and recalling memories. + +:::tip Alternative: MCP Integration +Cursor also supports MCP servers natively. If you prefer MCP over the plugin system, see [Local MCP Server](./local-mcp) and add Hindsight to `.cursor/mcp.json`: +```json +{ + "mcpServers": { + "hindsight": { "url": "http://localhost:8888/mcp/" } + } +} +``` +::: + +## Features + +- **Auto-recall** — on every user prompt, queries Hindsight for relevant memories and injects them as context via `additionalContext` (invisible to the chat, visible to the agent) +- **Auto-retain** — after every task completion, extracts and retains conversation content to Hindsight for long-term storage +- **On-demand recall** — use the `hindsight-recall` skill to manually query memories mid-conversation +- **Daemon management** — can auto-start/stop `hindsight-embed` locally or connect to an external Hindsight server +- **Dynamic bank IDs** — supports per-agent, per-project, or per-session memory isolation +- **Zero dependencies** — pure Python stdlib, no pip install required + +## Architecture + +The plugin uses Cursor's hook system: + +| Hook | Event | Purpose | +|------|-------|---------| +| `recall.py` | `beforeSubmitPrompt` | **Auto-recall** — query memories, inject as `additionalContext` | +| `retain.py` | `stop` | **Auto-retain** — extract transcript, POST to Hindsight | + +Additionally, the plugin provides: +- **Skill** (`hindsight-recall`) — on-demand memory querying via `/hindsight-recall` +- **Rule** (`hindsight-memory.mdc`) — always-on rule instructing the agent to leverage recalled memories proactively + +## Connection Modes + +### 1. External API (recommended for production) + +Connect to a running Hindsight server (cloud or self-hosted). No local LLM needed — the server handles fact extraction. + +```json +{ + "hindsightApiUrl": "https://your-hindsight-server.com", + "hindsightApiToken": "your-token" +} +``` + +### 2. Local Daemon (auto-managed) + +The plugin automatically starts and stops `hindsight-embed` via `uvx`. Requires an LLM provider API key for local fact extraction. + +Set an LLM provider: +```bash +export OPENAI_API_KEY="sk-your-key" +# or +export ANTHROPIC_API_KEY="your-key" +``` + +The model is selected automatically by the Hindsight API. To override, set `HINDSIGHT_LLM_MODEL`. + +### 3. Existing Local Server + +If you already have `hindsight-embed` running, leave `hindsightApiUrl` empty and set `apiPort` to match your server's port. The plugin will detect it automatically. + +## Configuration + +All settings live in `~/.hindsight/cursor.json`. Every setting can also be overridden via environment variables. The plugin ships with sensible defaults — you only need to configure what you want to change. + +**Loading order** (later entries win): +1. Built-in defaults (hardcoded in the plugin) +2. Plugin `settings.json` (ships with the plugin, at `CURSOR_PLUGIN_ROOT/settings.json`) +3. User config (`~/.hindsight/cursor.json` — recommended for your overrides) +4. Environment variables + +--- + +### Connection & Daemon + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `hindsightApiUrl` | `HINDSIGHT_API_URL` | `""` (empty) | URL of an external Hindsight API server. When empty, the plugin uses a local daemon instead. | +| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | `null` | Authentication token for the external API. Only needed when `hindsightApiUrl` is set. | +| `apiPort` | `HINDSIGHT_API_PORT` | `9077` | Port used by the local `hindsight-embed` daemon. | +| `daemonIdleTimeout` | `HINDSIGHT_DAEMON_IDLE_TIMEOUT` | `0` | Seconds of inactivity before the local daemon shuts itself down. `0` means the daemon stays running until the session ends. | +| `embedVersion` | `HINDSIGHT_EMBED_VERSION` | `"latest"` | Which version of `hindsight-embed` to install via `uvx`. | +| `embedPackagePath` | `HINDSIGHT_EMBED_PACKAGE_PATH` | `null` | Local path to a `hindsight-embed` checkout for development. | + +--- + +### LLM Provider (local daemon only) + +These settings configure which LLM the local daemon uses for fact extraction. They are **ignored** when connecting to an external API. + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `llmProvider` | `HINDSIGHT_LLM_PROVIDER` | auto-detect | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `ollama`. Auto-detects by checking for API key env vars. | +| `llmModel` | `HINDSIGHT_LLM_MODEL` | provider default | Override the default model for the chosen provider. | +| `llmApiKeyEnv` | — | provider standard | Name of the env var holding the API key, if non-standard. | + +--- + +### Memory Bank + +A **bank** is an isolated memory store — like a separate "brain." + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `bankId` | `HINDSIGHT_BANK_ID` | `"cursor"` | The bank ID when `dynamicBankId` is `false`. | +| `bankMission` | `HINDSIGHT_BANK_MISSION` | generic assistant prompt | Description of the agent's identity and purpose. | +| `dynamicBankId` | `HINDSIGHT_DYNAMIC_BANK_ID` | `false` | When `true`, derives a unique bank ID from context fields (see `dynamicBankGranularity`). | +| `dynamicBankGranularity` | — | `["agent", "project"]` | Fields to combine for dynamic bank IDs: `agent`, `project`, `session`, `channel`, `user`. | +| `bankIdPrefix` | — | `""` | String prepended to all bank IDs for namespacing. | +| `agentName` | `HINDSIGHT_AGENT_NAME` | `"cursor"` | Name used for the `agent` field in dynamic bank ID derivation. | + +--- + +### Auto-Recall + +Auto-recall runs on every user prompt. It queries Hindsight for relevant memories and injects them into the agent's context as invisible `additionalContext`. + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Master switch for auto-recall. | +| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Search thoroughness: `"low"`, `"mid"`, `"high"`. | +| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Max tokens in the recalled memory block. | +| `recallTypes` | — | `["world", "experience"]` | Memory types to retrieve. | +| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | Prior turns to include in the recall query. | +| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Max character length of the query. | + +--- + +### Auto-Retain + +Auto-retain runs after the agent completes a task. It extracts the conversation transcript and sends it to Hindsight. + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `autoRetain` | `HINDSIGHT_AUTO_RETAIN` | `true` | Master switch for auto-retain. | +| `retainMode` | `HINDSIGHT_RETAIN_MODE` | `"full-session"` | Retention strategy. `"full-session"` or `"chunked"`. | +| `retainEveryNTurns` | `HINDSIGHT_RETAIN_EVERY_N_TURNS` | `10` | How often to retain. `1` = every turn. | +| `retainOverlapTurns` | — | `2` | Extra turns included from the previous chunk for continuity. | +| `retainContext` | `HINDSIGHT_RETAIN_CONTEXT` | `"cursor"` | Source label for retained memories. | +| `retainToolCalls` | — | `false` | Whether to include tool calls in the retained transcript. | + +--- + +### Debug + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `debug` | `HINDSIGHT_DEBUG` | `false` | Enable verbose logging to stderr. Prefixed with `[Hindsight]`. | + +## Troubleshooting + +**Plugin not activating**: Check that `.cursor-plugin/plugin.json` exists in the plugin directory. Enable `"debug": true` in `~/.hindsight/cursor.json` and check stderr output. + +**Recall returning no memories**: Verify the Hindsight server is reachable (`curl http://localhost:9077/health`). Memories need at least one retain cycle. + +**Daemon not starting**: Ensure an LLM API key is set. Review daemon logs at `~/.hindsight/profiles/cursor.log`. + +**High latency on recall**: The recall hook has a 12-second timeout. Use `recallBudget: "low"` or reduce `recallMaxTokens`. diff --git a/hindsight-docs/sidebars.ts b/hindsight-docs/sidebars.ts index 483fa59b2..bfe253a9e 100644 --- a/hindsight-docs/sidebars.ts +++ b/hindsight-docs/sidebars.ts @@ -196,6 +196,12 @@ const sidebars: SidebarsConfig = { label: 'Claude Code', customProps: { icon: '/img/icons/claudecode.svg' }, }, + { + type: 'link', + href: '/sdks/integrations/cursor', + label: 'Cursor', + customProps: { icon: '/img/icons/terminal.svg' }, + }, { type: 'link', href: '/sdks/integrations/codex', diff --git a/hindsight-integrations/cursor/.cursor-plugin/plugin.json b/hindsight-integrations/cursor/.cursor-plugin/plugin.json new file mode 100644 index 000000000..e74086a70 --- /dev/null +++ b/hindsight-integrations/cursor/.cursor-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "hindsight-memory", + "displayName": "Hindsight Memory", + "version": "0.1.0", + "description": "Automatic long-term memory for Cursor via Hindsight. Recalls relevant memories before each prompt and retains conversation transcripts after each response.", + "author": {"name": "Hindsight Team", "url": "https://vectorize.io/hindsight"}, + "homepage": "https://vectorize.io/hindsight", + "repository": "https://github.com/vectorize-io/hindsight", + "license": "MIT", + "logo": "assets/avatar.png", + "keywords": ["memory", "hindsight", "recall", "retain", "long-term-memory"], + "category": "developer-tools", + "tags": ["automation", "memory", "transcripts"], + "hooks": "./hooks/hooks.json", + "skills": "./skills/", + "rules": "./rules/" +} diff --git a/hindsight-integrations/cursor/LICENSE b/hindsight-integrations/cursor/LICENSE new file mode 100644 index 000000000..8dce7b4d4 --- /dev/null +++ b/hindsight-integrations/cursor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Vectorize AI, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/hindsight-integrations/cursor/README.md b/hindsight-integrations/cursor/README.md new file mode 100644 index 000000000..f8634dd3e --- /dev/null +++ b/hindsight-integrations/cursor/README.md @@ -0,0 +1,183 @@ +# Hindsight Memory Plugin for Cursor + +Biomimetic long-term memory for [Cursor](https://cursor.com) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations and intelligently recalls relevant context using Cursor's plugin architecture. + +## Quick Start + +```bash +# 1. Install the plugin (copy or symlink into your project) +cp -r hindsight-integrations/cursor /path/to/your-project/.cursor-plugin/hindsight-memory + +# 2. Configure your LLM provider for memory extraction +# Option A: OpenAI (auto-detected) +export OPENAI_API_KEY="sk-your-key" + +# Option B: Anthropic (auto-detected) +export ANTHROPIC_API_KEY="your-key" + +# Option C: Connect to an external Hindsight server +mkdir -p ~/.hindsight +echo '{"hindsightApiUrl": "https://your-hindsight-server.com"}' > ~/.hindsight/cursor.json + +# 3. Open Cursor — the plugin activates automatically +``` + +## Features + +- **Auto-recall** — on every user prompt, queries Hindsight for relevant memories and injects them as context via `additionalContext` +- **Auto-retain** — after every response, extracts and retains conversation content to Hindsight for long-term storage +- **On-demand recall** — use the `hindsight-recall` skill to manually query memories +- **Daemon management** — can auto-start/stop `hindsight-embed` locally or connect to an external Hindsight server +- **Dynamic bank IDs** — supports per-agent, per-project, or per-session memory isolation +- **Zero dependencies** — pure Python stdlib, no pip install required + +## Architecture + +The plugin uses Cursor's hook system: + +| Hook | Event | Purpose | +|------|-------|---------| +| `recall.py` | `beforeSubmitPrompt` | **Auto-recall** — query memories, inject as `additionalContext` | +| `retain.py` | `stop` | **Auto-retain** — extract transcript, POST to Hindsight | + +### Library Modules + +| Module | Purpose | +|--------|---------| +| `lib/client.py` | Hindsight REST API client (stdlib `urllib`) | +| `lib/config.py` | Configuration loader (settings.json + env overrides) | +| `lib/daemon.py` | `hindsight-embed` daemon lifecycle (start/stop/health) | +| `lib/bank.py` | Bank ID derivation + mission management | +| `lib/content.py` | Content processing (transcript parsing, memory formatting, tag stripping) | +| `lib/state.py` | File-based state persistence with `fcntl` locking | +| `lib/llm.py` | LLM provider auto-detection for daemon mode | + +### How Recall Works + +1. User sends a prompt -> `beforeSubmitPrompt` hook fires +2. Plugin resolves Hindsight API URL (external, local, or auto-start daemon) +3. Derives bank ID (static or dynamic from project context) +4. Composes query from current prompt + optional prior turns +5. Calls Hindsight recall API +6. Formats memories into `` block +7. Outputs via `hookSpecificOutput.additionalContext` — the agent sees it, user doesn't + +### How Retain Works + +1. Agent completes a task -> `stop` hook fires +2. Reads conversation transcript from Cursor's JSONL file +3. Applies chunked retention logic (every N turns with sliding window) +4. Strips `` tags to prevent feedback loops +5. POSTs formatted transcript to Hindsight retain API + +## Connection Modes + +### 1. External API (recommended for production) + +Connect to a running Hindsight server (cloud or self-hosted). + +```json +{ + "hindsightApiUrl": "https://your-hindsight-server.com", + "hindsightApiToken": "your-token" +} +``` + +### 2. Local Daemon (auto-managed) + +The plugin automatically starts and stops `hindsight-embed` via `uvx`. Requires an LLM provider API key. + +```json +{ + "hindsightApiUrl": "", + "apiPort": 9077 +} +``` + +### 3. Existing Local Server + +If you already have `hindsight-embed` running, leave `hindsightApiUrl` empty and set `apiPort` to match your server's port. + +## Configuration + +All settings live in `~/.hindsight/cursor.json`. Every setting can also be overridden via environment variables. The plugin ships with sensible defaults. + +**Loading order** (later entries win): +1. Built-in defaults (hardcoded in the plugin) +2. Plugin `settings.json` (ships with the plugin) +3. User config (`~/.hindsight/cursor.json`) +4. Environment variables + +### Connection & Daemon + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `hindsightApiUrl` | `HINDSIGHT_API_URL` | `""` | URL of an external Hindsight API server | +| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | `null` | Authentication token for the external API | +| `apiPort` | `HINDSIGHT_API_PORT` | `9077` | Port for the local `hindsight-embed` daemon | +| `embedVersion` | `HINDSIGHT_EMBED_VERSION` | `"latest"` | Version of `hindsight-embed` to install | + +### Memory Bank + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `bankId` | `HINDSIGHT_BANK_ID` | `"cursor"` | Bank ID when `dynamicBankId` is false | +| `dynamicBankId` | `HINDSIGHT_DYNAMIC_BANK_ID` | `false` | Derive bank ID from context fields | +| `agentName` | `HINDSIGHT_AGENT_NAME` | `"cursor"` | Agent name for dynamic bank ID | + +### Auto-Recall + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Enable/disable auto-recall | +| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Search thoroughness: low, mid, high | +| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Max tokens in recalled memory block | + +### Auto-Retain + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `autoRetain` | `HINDSIGHT_AUTO_RETAIN` | `true` | Enable/disable auto-retain | +| `retainEveryNTurns` | `HINDSIGHT_RETAIN_EVERY_N_TURNS` | `10` | Retain frequency | +| `retainContext` | `HINDSIGHT_RETAIN_CONTEXT` | `"cursor"` | Source label for retained memories | + +### Debug + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| `debug` | `HINDSIGHT_DEBUG` | `false` | Enable verbose logging to stderr | + +## Alternative: MCP Integration + +Cursor also supports MCP servers natively. If you prefer MCP over the plugin system, you can connect directly to Hindsight's MCP endpoint: + +```json +// .cursor/mcp.json +{ + "mcpServers": { + "hindsight": { + "url": "http://localhost:8888/mcp/" + } + } +} +``` + +This gives you access to all Hindsight tools (retain, recall, reflect) without the plugin. + +## Development + +```bash +# Run tests +pip install pytest +python -m pytest tests/ -v +``` + +## Links + +- [Hindsight Documentation](https://vectorize.io/hindsight) +- [Cursor Documentation](https://docs.cursor.com) +- [GitHub Repository](https://github.com/vectorize-io/hindsight) + +## License + +MIT diff --git a/hindsight-integrations/cursor/hooks/hooks.json b/hindsight-integrations/cursor/hooks/hooks.json new file mode 100644 index 000000000..22db9d900 --- /dev/null +++ b/hindsight-integrations/cursor/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "hooks": { + "beforeSubmitPrompt": [ + { + "command": "python3 \"${CURSOR_PLUGIN_ROOT}/scripts/recall.py\"", + "timeout": 12 + } + ], + "stop": [ + { + "command": "python3 \"${CURSOR_PLUGIN_ROOT}/scripts/retain.py\"", + "timeout": 15 + } + ] + } +} diff --git a/hindsight-integrations/cursor/rules/hindsight-memory.mdc b/hindsight-integrations/cursor/rules/hindsight-memory.mdc new file mode 100644 index 000000000..99229265c --- /dev/null +++ b/hindsight-integrations/cursor/rules/hindsight-memory.mdc @@ -0,0 +1,12 @@ +--- +description: Use Hindsight long-term memory to recall context from past sessions and retain important decisions +alwaysApply: true +--- + +You have access to Hindsight long-term memory through MCP tools. Use them proactively: + +- **Before answering questions** about project architecture, past decisions, or user preferences, use `recall` to check if relevant context exists from prior sessions. +- **After the user shares important information** like architectural decisions, tool preferences, project conventions, or debugging insights, use `retain` to store it for future sessions. +- **For complex questions**, use `reflect` to get a synthesized answer from memory rather than raw recall results. + +Do not recall for simple, self-contained coding tasks. Only use memory when prior context would genuinely improve your response. diff --git a/hindsight-integrations/cursor/scripts/lib/__init__.py b/hindsight-integrations/cursor/scripts/lib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/hindsight-integrations/cursor/scripts/lib/bank.py b/hindsight-integrations/cursor/scripts/lib/bank.py new file mode 100644 index 000000000..c7004ed6b --- /dev/null +++ b/hindsight-integrations/cursor/scripts/lib/bank.py @@ -0,0 +1,83 @@ +"""Bank ID derivation and mission management for Cursor plugin. + +Supports static bank IDs ("cursor") or dynamic per-project IDs +derived from the working directory. +""" + +import os +import sys +import urllib.parse + +from .state import read_state, write_state + +DEFAULT_BANK_NAME = "cursor" + +VALID_FIELDS = {"agent", "project", "channel", "user"} + + +def derive_bank_id(hook_input: dict, config: dict) -> str: + """Derive a bank ID from hook context and config. + + When dynamicBankId is false, returns the static bank. + When true, composes from granularity fields joined by '::'. + """ + prefix = config.get("bankIdPrefix", "") + + if not config.get("dynamicBankId", False): + base = config.get("bankId") or DEFAULT_BANK_NAME + return f"{prefix}-{base}" if prefix else base + + fields = config.get("dynamicBankGranularity") + if not fields or not isinstance(fields, list): + fields = ["agent", "project"] + + for f in fields: + if f not in VALID_FIELDS: + print( + f'[Hindsight] Unknown dynamicBankGranularity field "{f}" -- ' + f"valid for Cursor: {', '.join(sorted(VALID_FIELDS))}", + file=sys.stderr, + ) + + cwd = hook_input.get("cwd", "") + agent_name = config.get("agentName", "cursor") + channel_id = os.environ.get("HINDSIGHT_CHANNEL_ID", "") + user_id = os.environ.get("HINDSIGHT_USER_ID", "") + + field_map = { + "agent": agent_name, + "project": os.path.basename(cwd) if cwd else "unknown", + "channel": channel_id or "default", + "user": user_id or "anonymous", + } + + segments = [urllib.parse.quote(field_map.get(f, "unknown"), safe="") for f in fields] + base_bank_id = "::".join(segments) + + return f"{prefix}-{base_bank_id}" if prefix else base_bank_id + + +def ensure_bank_mission(client, bank_id: str, config: dict, debug_fn=None): + """Set bank mission on first use, skip if already set.""" + mission = config.get("bankMission", "") + if not mission or not mission.strip(): + return + + missions_set = read_state("bank_missions.json", {}) + if bank_id in missions_set: + return + + try: + retain_mission = config.get("retainMission") + client.set_bank_mission(bank_id, mission, retain_mission=retain_mission, timeout=10) + missions_set[bank_id] = True + if len(missions_set) > 10000: + keys = sorted(missions_set.keys()) + for k in keys[: len(keys) // 2]: + del missions_set[k] + write_state("bank_missions.json", missions_set) + if debug_fn: + debug_fn(f"Set mission for bank: {bank_id}") + except Exception as e: + if debug_fn: + debug_fn(f"Could not set bank mission for {bank_id}: {e}") diff --git a/hindsight-integrations/cursor/scripts/lib/client.py b/hindsight-integrations/cursor/scripts/lib/client.py new file mode 100644 index 000000000..58b7bc4c2 --- /dev/null +++ b/hindsight-integrations/cursor/scripts/lib/client.py @@ -0,0 +1,145 @@ +"""Hindsight REST API client. + +Communicates with a Hindsight server via HTTP. Mirrors the HTTP mode of the +Openclaw HindsightClient (client.js), adapted for Python stdlib. +""" + +import json +import urllib.error +import urllib.parse +import urllib.request +from typing import Optional + +DEFAULT_TIMEOUT = 15 # seconds +HEALTH_CHECK_RETRIES = 3 +HEALTH_CHECK_DELAY = 2 # seconds + + +def _validate_api_url(url: str) -> str: + """Validate and normalize the API URL. Reject non-HTTP schemes.""" + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Hindsight API URL must use http or https, got: {parsed.scheme!r}") + if not parsed.hostname: + raise ValueError(f"Hindsight API URL has no hostname: {url!r}") + return url.rstrip("/") + + +class HindsightClient: + """HTTP client for the Hindsight API.""" + + def __init__(self, api_url: str, api_token: Optional[str] = None): + self.api_url = _validate_api_url(api_url) + self.api_token = api_token + + def _headers(self) -> dict: + headers = {"Content-Type": "application/json"} + if self.api_token: + headers["Authorization"] = f"Bearer {self.api_token}" + return headers + + def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict: + url = f"{self.api_url}{path}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, headers=self._headers(), method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + body_text = "" + try: + body_text = e.read().decode() + except Exception: + pass + raise RuntimeError(f"HTTP {e.code} from {url}: {body_text}") from e + + def health_check(self, timeout: int = 5) -> bool: + """Check if the Hindsight server is reachable. + + Mirrors Openclaw's checkExternalApiHealth: retries up to 3 times + with 2s delay between attempts. + """ + import time + + for attempt in range(1, HEALTH_CHECK_RETRIES + 1): + try: + url = f"{self.api_url}/health" + req = urllib.request.Request(url, headers=self._headers(), method="GET") + with urllib.request.urlopen(req, timeout=timeout) as resp: + if resp.status == 200: + return True + except Exception: + pass + if attempt < HEALTH_CHECK_RETRIES: + time.sleep(HEALTH_CHECK_DELAY) + return False + + def recall( + self, + bank_id: str, + query: str, + max_tokens: int = 1024, + budget: str = "mid", + types: Optional[list] = None, + timeout: int = 10, + ) -> dict: + """Recall memories from a bank. + + Returns the raw API response dict with 'results' list. + """ + path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall" + body = { + "query": query, + "max_tokens": max_tokens, + } + if budget: + body["budget"] = budget + if types: + body["types"] = types + return self._request("POST", path, body, timeout=timeout) + + def retain( + self, + bank_id: str, + content: str, + document_id: str = "conversation", + context: Optional[str] = None, + metadata: Optional[dict] = None, + tags: Optional[list] = None, + timeout: int = 15, + ) -> dict: + """Retain content into a bank's memory. + + Posts with async=true so the server processes in the background. + The context field helps Hindsight cluster memories by provenance + (e.g. "claude-code" vs manual retains). + """ + path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories" + item = { + "content": content, + "document_id": document_id, + "metadata": metadata or {}, + } + if context: + item["context"] = context + if tags: + item["tags"] = tags + body = { + "items": [item], + "async": True, + } + return self._request("POST", path, body, timeout=timeout) + + def set_bank_mission( + self, bank_id: str, mission: str, retain_mission: Optional[str] = None, timeout: int = 15 + ) -> dict: + """Set the mission/persona for a bank. + + Uses PATCH /banks/{id}/config with reflect_mission and retain_mission. + The old PUT /banks/{id} with 'mission' field is deprecated in v0.4.19. + """ + path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/config" + updates = {"reflect_mission": mission} + if retain_mission: + updates["retain_mission"] = retain_mission + return self._request("PATCH", path, {"updates": updates}, timeout=timeout) diff --git a/hindsight-integrations/cursor/scripts/lib/config.py b/hindsight-integrations/cursor/scripts/lib/config.py new file mode 100644 index 000000000..358ed937c --- /dev/null +++ b/hindsight-integrations/cursor/scripts/lib/config.py @@ -0,0 +1,142 @@ +"""Configuration management for Hindsight Cursor plugin. + +Loads settings from settings.json (plugin defaults) merged with environment +variable overrides. Follows the same layering as the Claude Code integration. +""" + +import json +import os +import sys + +DEFAULTS = { + # Recall + "autoRecall": True, + "recallBudget": "mid", + "recallMaxTokens": 1024, + "recallTypes": ["world", "experience"], + "recallContextTurns": 1, + "recallMaxQueryChars": 800, + "recallPromptPreamble": ( + "Relevant memories from past coding sessions (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:" + ), + # Retain + "autoRetain": True, + "retainMode": "full-session", + "retainEveryNTurns": 10, + "retainOverlapTurns": 2, + "retainToolCalls": False, + "retainContext": "cursor", + "retainTags": [], + "retainMetadata": {}, + # Connection + "hindsightApiUrl": None, + "hindsightApiToken": None, + "apiPort": 9077, + "daemonIdleTimeout": 0, + "embedVersion": "latest", + "embedPackagePath": None, + # Bank + "bankId": None, + "bankIdPrefix": "", + "dynamicBankId": False, + "dynamicBankGranularity": ["agent", "project"], + "bankMission": "", + "retainMission": None, + "agentName": "cursor", + # LLM (for daemon mode) + "llmProvider": None, + "llmModel": None, + "llmApiKeyEnv": None, + # Misc + "debug": False, +} + +# Map env var names to config keys and their types +ENV_OVERRIDES = { + "HINDSIGHT_API_URL": ("hindsightApiUrl", str), + "HINDSIGHT_API_TOKEN": ("hindsightApiToken", str), + "HINDSIGHT_BANK_ID": ("bankId", str), + "HINDSIGHT_AGENT_NAME": ("agentName", str), + "HINDSIGHT_AUTO_RECALL": ("autoRecall", bool), + "HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool), + "HINDSIGHT_RETAIN_MODE": ("retainMode", str), + "HINDSIGHT_RECALL_BUDGET": ("recallBudget", str), + "HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int), + "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int), + "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int), + "HINDSIGHT_API_PORT": ("apiPort", int), + "HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int), + "HINDSIGHT_EMBED_VERSION": ("embedVersion", str), + "HINDSIGHT_EMBED_PACKAGE_PATH": ("embedPackagePath", str), + "HINDSIGHT_RETAIN_EVERY_N_TURNS": ("retainEveryNTurns", int), + "HINDSIGHT_RETAIN_CONTEXT": ("retainContext", str), + "HINDSIGHT_DYNAMIC_BANK_ID": ("dynamicBankId", bool), + "HINDSIGHT_BANK_MISSION": ("bankMission", str), + "HINDSIGHT_LLM_PROVIDER": ("llmProvider", str), + "HINDSIGHT_LLM_MODEL": ("llmModel", str), + "HINDSIGHT_DEBUG": ("debug", bool), +} + + +def _cast_env(value: str, typ): + """Cast environment variable string to target type. Returns None on failure.""" + try: + if typ is bool: + return value.lower() in ("true", "1", "yes") + if typ is int: + return int(value) + return value + except (ValueError, AttributeError): + return None + + +def _load_settings_file(path: str, config: dict) -> None: + """Merge a settings.json file into config in-place. Silently skips if missing.""" + if not os.path.exists(path): + return + try: + with open(path) as f: + file_config = json.load(f) + config.update({k: v for k, v in file_config.items() if v is not None}) + except (json.JSONDecodeError, OSError) as e: + debug_log(config, f"Failed to load {path}: {e}") + + +def load_config() -> dict: + """Load plugin configuration from settings.json + env overrides. + + Loading order (later entries win): + 1. Built-in defaults + 2. Plugin default settings.json (CURSOR_PLUGIN_ROOT/settings.json) + 3. User config (~/.hindsight/cursor.json) + 4. Environment variable overrides + """ + config = dict(DEFAULTS) + + # 1. Plugin default settings.json + plugin_root = os.environ.get("CURSOR_PLUGIN_ROOT", "") + if not plugin_root: + plugin_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + _load_settings_file(os.path.join(plugin_root, "settings.json"), config) + + # 2. User config + user_config_path = os.path.join(os.path.expanduser("~"), ".hindsight", "cursor.json") + _load_settings_file(user_config_path, config) + + # Apply environment variable overrides + for env_name, (key, typ) in ENV_OVERRIDES.items(): + val = os.environ.get(env_name) + if val is not None: + cast_val = _cast_env(val, typ) + if cast_val is not None: + config[key] = cast_val + + return config + + +def debug_log(config: dict, *args): + """Log to stderr if debug mode is enabled.""" + if config.get("debug"): + print("[Hindsight]", *args, file=sys.stderr) diff --git a/hindsight-integrations/cursor/scripts/lib/content.py b/hindsight-integrations/cursor/scripts/lib/content.py new file mode 100644 index 000000000..fa4ac8860 --- /dev/null +++ b/hindsight-integrations/cursor/scripts/lib/content.py @@ -0,0 +1,482 @@ +"""Content processing utilities. + +Faithful port of Openclaw plugin's content processing: memory tag stripping, +query composition/truncation, transcript formatting, and memory formatting. + +Source: reference/openclaw-source/index.js — stripMemoryTags, composeRecallQuery, +truncateRecallQuery, sliceLastTurnsByUserBoundary, prepareRetentionTranscript, +formatMemories. +""" + +import re +from datetime import datetime, timezone + +# --------------------------------------------------------------------------- +# Memory tag stripping (anti-feedback-loop) +# --------------------------------------------------------------------------- + + +def strip_channel_envelope(content: str) -> str: + """Strip Claude Code channel XML wrappers from user messages. + + Claude Code wraps incoming channel messages in XML: + + actual message text + + + This is the Claude Code equivalent of Openclaw's stripMetadataEnvelopes(). + Extracts the inner text, preserving the actual user message while removing + transport metadata that Hindsight doesn't need. + """ + # Match content — extract inner text + match = re.search(r"]*>([\s\S]*?)", content) + if match: + return match.group(1).strip() + return content + + +def strip_memory_tags(content: str) -> str: + """Remove and blocks. + + Prevents retain feedback loop — these were injected during recall and + should not be re-stored. + + Port of: stripMemoryTags() in index.js + """ + content = re.sub(r"[\s\S]*?", "", content) + content = re.sub(r"[\s\S]*?", "", content) + return content + + +# --------------------------------------------------------------------------- +# Recall: query composition and truncation +# --------------------------------------------------------------------------- + + +def compose_recall_query( + latest_query: str, + messages: list, + recall_context_turns: int, + recall_roles: list = None, +) -> str: + """Compose a multi-turn recall query from conversation history. + + Port of: composeRecallQuery() in index.js + + When recallContextTurns > 1, includes prior context from the transcript + above the latest user query. Format: + + Prior context: + + user: ... + assistant: ... + + + """ + latest = latest_query.strip() + if recall_context_turns <= 1 or not isinstance(messages, list) or not messages: + return latest + + allowed_roles = set(recall_roles or ["user", "assistant"]) + contextual_messages = slice_last_turns_by_user_boundary(messages, recall_context_turns) + + context_lines = [] + for msg in contextual_messages: + role = msg.get("role") + if role not in allowed_roles: + continue + + content = _extract_text_content(msg.get("content", ""), role=role) + content = strip_channel_envelope(content) + content = strip_memory_tags(content).strip() + if not content: + continue + + # Skip if this is the same as the latest query (avoid duplication) + if role == "user" and content == latest: + continue + + context_lines.append(f"{role}: {content}") + + if not context_lines: + return latest + + return "\n\n".join( + [ + "Prior context:", + "\n".join(context_lines), + latest, + ] + ) + + +def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str: + """Truncate a composed recall query to max_chars. + + Port of: truncateRecallQuery() in index.js + + Preserves the latest user message. When the query contains "Prior context:", + drops oldest context lines first (from the top) to fit within the limit. + """ + if max_chars <= 0: + return query + + latest = latest_query.strip() + if len(query) <= max_chars: + return query + + # If even the latest alone is too long, hard-truncate it + latest_only = latest[:max_chars] if len(latest) > max_chars else latest + + if "Prior context:" not in query: + return latest_only + + context_marker = "Prior context:\n\n" + marker_index = query.find(context_marker) + if marker_index == -1: + return latest_only + + suffix_marker = "\n\n" + latest + suffix_index = query.rfind(suffix_marker) + if suffix_index == -1: + return latest_only + + suffix = query[suffix_index:] # \n\n + if len(suffix) >= max_chars: + return latest_only + + context_body = query[marker_index + len(context_marker) : suffix_index] + context_lines = [line for line in context_body.split("\n") if line] + + # Add context lines from newest (bottom) to oldest (top), stop when exceeding + kept = [] + for i in range(len(context_lines) - 1, -1, -1): + kept.insert(0, context_lines[i]) + candidate = f"{context_marker}{chr(10).join(kept)}{suffix}" + if len(candidate) > max_chars: + kept.pop(0) + break + + if kept: + return f"{context_marker}{chr(10).join(kept)}{suffix}" + return latest_only + + +# --------------------------------------------------------------------------- +# Turn slicing +# --------------------------------------------------------------------------- + + +def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list: + """Slice messages to the last N turns, where a turn starts at a user message. + + Port of: sliceLastTurnsByUserBoundary() in index.js + + Walks backward counting user messages as turn boundaries. Returns + messages from the Nth user boundary to the end. + """ + if not isinstance(messages, list) or not messages or turns <= 0: + return [] + + user_turns_seen = 0 + start_index = -1 + + for i in range(len(messages) - 1, -1, -1): + if messages[i].get("role") == "user": + user_turns_seen += 1 + if user_turns_seen >= turns: + start_index = i + break + + if start_index == -1: + return list(messages) + + return messages[start_index:] + + +# --------------------------------------------------------------------------- +# Memory formatting (recall results → context string) +# --------------------------------------------------------------------------- + + +def format_memories(results: list) -> str: + """Format recall results into human-readable text. + + Port of: formatMemories() in index.js + Format: - [] () + """ + if not results: + return "" + lines = [] + for r in results: + text = r.get("text", "") + mem_type = r.get("type", "") + mentioned_at = r.get("mentioned_at", "") + type_str = f" [{mem_type}]" if mem_type else "" + date_str = f" ({mentioned_at})" if mentioned_at else "" + lines.append(f"- {text}{type_str}{date_str}") + return "\n\n".join(lines) + + +def format_current_time() -> str: + """Format current UTC time for recall context. + + Port of: formatCurrentTimeForRecall() in index.js + """ + now = datetime.now(timezone.utc) + return now.strftime("%Y-%m-%d %H:%M") + + +# --------------------------------------------------------------------------- +# Retention transcript formatting +# --------------------------------------------------------------------------- + + +def _extract_message_blocks(content, role: str = "") -> list: + """Extract structured content blocks from a message for JSON retention. + + Returns a list of dicts, each representing a content block: + - {"type": "text", "text": "..."} for text blocks + - {"type": "tool_use", "name": "...", "input": {...}} for tool calls + - Channel message tool_use blocks get their text extracted inline. + """ + if isinstance(content, str): + cleaned = strip_channel_envelope(strip_memory_tags(content)).strip() + return [{"type": "text", "text": cleaned}] if cleaned else [] + + if not isinstance(content, list): + return [] + + blocks = [] + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type", "") + + if block_type == "text": + text = strip_channel_envelope(strip_memory_tags(block.get("text", ""))).strip() + if text: + blocks.append({"type": "text", "text": text}) + + elif block_type == "tool_use" and role == "assistant": + if _is_channel_message_tool(block): + # Channel messages: extract the outgoing text + tool_input = block.get("input", {}) + for field in _MESSAGE_TEXT_FIELDS: + val = tool_input.get(field) + if isinstance(val, str) and val.strip(): + blocks.append({"type": "text", "text": val.strip()}) + break + else: + name = block.get("name", "unknown") + inp = block.get("input", {}) + # Skip Hindsight MCP tools to avoid feedback loops + if name.startswith("mcp__") and _OPERATIONAL_TOOL_PATTERN.search(name.split("__")[-1]): + continue + blocks.append({"type": "tool_use", "name": name, "input": inp}) + + elif block_type == "tool_result": + # Include tool results for context + result_content = block.get("content", "") + if isinstance(result_content, str) and result_content.strip(): + text = result_content.strip() + # Truncate very long results + if len(text) > 2000: + text = text[:2000] + "... (truncated)" + blocks.append({"type": "tool_result", "tool_use_id": block.get("tool_use_id", ""), "content": text}) + + return blocks + + +def prepare_retention_transcript( + messages: list, + retain_roles: list = None, + retain_full_window: bool = False, + include_tool_calls: bool = False, +) -> tuple: + """Format messages into a retention transcript. + + When include_tool_calls is True, outputs JSON with full message structure + including tool calls and their inputs. Otherwise outputs the legacy + text format with [role: ...]...[role:end] markers. + + Args: + messages: List of message dicts with 'role' and 'content'. + retain_roles: Roles to include (default: ['user', 'assistant']). + retain_full_window: If True, retain all messages (chunked mode). + If False, retain only the last turn (last user msg + responses). + include_tool_calls: If True, output JSON format with full tool call data. + + Returns: + (transcript_text, message_count) or (None, 0) if nothing to retain. + """ + if not messages: + return None, 0 + + if retain_full_window: + target_messages = messages + else: + # Default: retain only the last turn + last_user_idx = -1 + for i in range(len(messages) - 1, -1, -1): + if messages[i].get("role") == "user": + last_user_idx = i + break + if last_user_idx == -1: + return None, 0 + target_messages = messages[last_user_idx:] + + allowed_roles = set(retain_roles or ["user", "assistant"]) + + if include_tool_calls: + return _prepare_json_transcript(target_messages, allowed_roles) + return _prepare_text_transcript(target_messages, allowed_roles) + + +def _prepare_json_transcript(messages: list, allowed_roles: set) -> tuple: + """Format messages as JSON with full tool call data.""" + import json + + structured_messages = [] + for msg in messages: + role = msg.get("role", "unknown") + if role not in allowed_roles: + continue + + blocks = _extract_message_blocks(msg.get("content", ""), role=role) + if not blocks: + continue + + structured_messages.append({"role": role, "content": blocks}) + + if not structured_messages: + return None, 0 + + transcript = json.dumps(structured_messages, indent=None, ensure_ascii=False) + if len(transcript.strip()) < 10: + return None, 0 + + return transcript, len(structured_messages) + + +def _prepare_text_transcript(messages: list, allowed_roles: set) -> tuple: + """Format messages as legacy text with [role:]...[role:end] markers.""" + parts = [] + + for msg in messages: + role = msg.get("role", "unknown") + if role not in allowed_roles: + continue + + content = _extract_text_content(msg.get("content", ""), role=role) + content = strip_channel_envelope(content) + content = strip_memory_tags(content).strip() + + if not content: + continue + + parts.append(f"[role: {role}]\n{content}\n[{role}:end]") + + if not parts: + return None, 0 + + transcript = "\n\n".join(parts) + if len(transcript.strip()) < 10: + return None, 0 + + return transcript, len(parts) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Fields in tool_use input that carry the outgoing message text. +# Ordered by likelihood — first match wins. +_MESSAGE_TEXT_FIELDS = ("text", "body", "message", "content") + +# MCP tool name suffixes that are operational, not conversational. +# Checked against the last segment of the tool name (after the last __). +import re as _re + +_OPERATIONAL_TOOL_PATTERN = _re.compile( + r"(?:recall|retain|reflect|search|extract|create_|delete_|update_|get_|list_)", + _re.IGNORECASE, +) + + +def _is_channel_message_tool(block: dict) -> bool: + """Detect if a tool_use block is a channel message (reply/send). + + Uses a structural approach rather than name-matching for robustness: + 1. Must be an MCP tool (name starts with "mcp__") + 2. Must NOT match known operational patterns (recall, search, CRUD) + 3. Must have a text-like field in input (text, body, message, content) + + This catches any channel plugin (Telegram, Slack, Discord, Matrix, + future channels) without hardcoding tool names. Built-in tools (Bash, + Read, Write) don't start with mcp__. MCP tools for non-messaging + purposes (hindsight recall, search) are excluded by pattern and by + lacking text/body fields. + """ + name = block.get("name", "") + if not name.startswith("mcp__"): + return False + + # Exclude operational MCP tools (check only the tool suffix, not server name) + tool_suffix = name.split("__")[-1] + if _OPERATIONAL_TOOL_PATTERN.search(tool_suffix): + return False + + tool_input = block.get("input", {}) + if not isinstance(tool_input, dict): + return False + + # Must have a text-carrying field with actual content + return any(isinstance(tool_input.get(f), str) and tool_input[f].strip() for f in _MESSAGE_TEXT_FIELDS) + + +def _extract_text_content(content, role: str = "") -> str: + """Extract text from message content (string or content blocks array). + + For user messages: extracts from plain strings (channel XML wrappers + are stripped separately by strip_channel_envelope). + + For assistant messages: extracts from: + - {type: "text"} blocks — terminal output/narration + - {type: "tool_use"} blocks detected as channel messages — the agent's + actual responses to the user. Detection is structural (MCP tool with + text-like input field), not name-based, for channel-agnosticism. + + Excludes: + - {type: "thinking"} — internal reasoning + - {type: "tool_use"} for operational tools — Bash, Read, Write, recall, etc. + - {type: "tool_result"} — operational results, not conversation + """ + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [] + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type", "") + + # Text blocks: terminal output / narration + if block_type == "text": + text = block.get("text", "").strip() + if text: + texts.append(text) + + # Tool use blocks: extract channel messages + elif block_type == "tool_use" and role == "assistant": + if _is_channel_message_tool(block): + tool_input = block.get("input", {}) + for field in _MESSAGE_TEXT_FIELDS: + val = tool_input.get(field) + if isinstance(val, str) and val.strip(): + texts.append(val.strip()) + break + + return "\n".join(texts) + return "" diff --git a/hindsight-integrations/cursor/scripts/lib/daemon.py b/hindsight-integrations/cursor/scripts/lib/daemon.py new file mode 100644 index 000000000..9305c0536 --- /dev/null +++ b/hindsight-integrations/cursor/scripts/lib/daemon.py @@ -0,0 +1,237 @@ +"""Hindsight-embed daemon lifecycle management for Cursor plugin. + +Manages three connection modes: + 1. External API -- user provides hindsightApiUrl (skip daemon entirely) + 2. Existing local server -- user already has hindsight running + 3. Auto-managed daemon -- plugin starts/stops hindsight-embed +""" + +import os +import platform +import subprocess +import time +import urllib.error +import urllib.request + +from .llm import detect_llm_config, get_llm_env_vars +from .state import read_state, write_state + +DAEMON_STATE_FILE = "daemon.json" +PROFILE_NAME = "cursor" + + +def _get_embed_command(config: dict) -> list: + """Get the command to run hindsight-embed.""" + embed_path = config.get("embedPackagePath") + if embed_path: + return ["uv", "run", "--directory", embed_path, "hindsight-embed"] + + version = config.get("embedVersion", "latest") + package = f"hindsight-embed@{version}" if version else "hindsight-embed@latest" + return ["uvx", package] + + +def _run_embed(config: dict, args: list, env: dict = None, timeout: int = 10) -> subprocess.CompletedProcess: + """Run a hindsight-embed command and return the result.""" + cmd = _get_embed_command(config) + args + run_env = dict(os.environ) + if env: + run_env.update(env) + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + env=run_env, + ) + + +def _is_embed_available(config: dict) -> bool: + """Quick check if hindsight-embed is available on PATH.""" + import shutil + + embed_path = config.get("embedPackagePath") + if embed_path: + return os.path.isdir(embed_path) + return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None + + +def _check_health(base_url: str, timeout: int = 2) -> bool: + """Quick health check against a Hindsight server.""" + try: + url = f"{base_url.rstrip('/')}/health" + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status == 200 + except Exception: + return False + + +def get_api_url(config: dict, debug_fn=None, allow_daemon_start: bool = False) -> str: + """Determine the API URL, optionally starting daemon if needed. + + Connection mode priority: + 1. External API (hindsightApiUrl configured) + 2. Existing local server (check port health) + 3. Auto-managed daemon (only if allow_daemon_start=True) + """ + # Mode 1: External API + external_url = config.get("hindsightApiUrl") + if external_url: + if debug_fn: + debug_fn(f"Using external API: {external_url}") + return external_url + + # Mode 2 & 3: Local server + port = config.get("apiPort", 9077) + base_url = f"http://127.0.0.1:{port}" + + if _check_health(base_url): + if debug_fn: + debug_fn(f"Existing server healthy on port {port}") + return base_url + + # Mode 3: Auto-start daemon + if not allow_daemon_start: + raise RuntimeError( + f"No Hindsight server on port {port}. Set hindsightApiUrl for external " + f"API, start hindsight-embed manually, or wait for the retain hook to " + f"auto-start the daemon." + ) + + if debug_fn: + debug_fn(f"No server on port {port}, attempting daemon start") + + try: + _ensure_daemon_running(config, port, debug_fn) + except Exception as e: + if debug_fn: + debug_fn(f"Daemon start failed: {e}") + raise RuntimeError( + "No Hindsight server available. Set hindsightApiUrl for external API, " + "or ensure hindsight-embed is installed for local daemon mode." + ) from e + + return base_url + + +def _ensure_daemon_running(config: dict, port: int, debug_fn=None): + """Start the hindsight-embed daemon if not already running.""" + if not _is_embed_available(config): + raise RuntimeError( + "hindsight-embed not found (uvx not on PATH). " + "Install with: pip install hindsight-embed, or set hindsightApiUrl." + ) + + base_url = f"http://127.0.0.1:{port}" + + try: + llm_config = detect_llm_config(config) + except RuntimeError as e: + raise RuntimeError(f"Cannot start daemon: {e}") from e + + llm_env = get_llm_env_vars(llm_config) + + daemon_env = dict(llm_env) + idle_timeout = config.get("daemonIdleTimeout", 300) + daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout) + + if platform.system() == "Darwin": + daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1" + daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1" + + # Step 1: Configure profile + if debug_fn: + debug_fn(f'Configuring "{PROFILE_NAME}" profile...') + + profile_args = [ + "profile", + "create", + PROFILE_NAME, + "--merge", + "--port", + str(port), + ] + for env_name, env_val in daemon_env.items(): + if env_val: + profile_args.extend(["--env", f"{env_name}={env_val}"]) + + try: + result = _run_embed(config, profile_args, daemon_env, timeout=10) + if result.returncode != 0: + if debug_fn: + debug_fn(f"Profile create stderr: {result.stderr.strip()}") + raise RuntimeError(f"Profile create failed (exit {result.returncode}): {result.stderr}") + except subprocess.TimeoutExpired: + raise RuntimeError("Profile create timed out") + except FileNotFoundError: + raise RuntimeError( + "hindsight-embed not found. Install with: pip install hindsight-embed " + "or set hindsightApiUrl for external API mode." + ) + + # Step 2: Start daemon + if debug_fn: + debug_fn("Starting daemon...") + + try: + result = _run_embed( + config, + ["daemon", "--profile", PROFILE_NAME, "start"], + daemon_env, + timeout=30, + ) + if debug_fn: + debug_fn(f"Daemon start exit={result.returncode} stdout={result.stdout.strip()}") + if result.returncode != 0 and "already running" not in result.stderr.lower(): + raise RuntimeError(f"Daemon start failed (exit {result.returncode}): {result.stderr}") + except subprocess.TimeoutExpired: + raise RuntimeError("Daemon start timed out") + + # Step 3: Wait for ready + if debug_fn: + debug_fn("Waiting for daemon to be ready...") + + for attempt in range(30): + if _check_health(base_url): + if debug_fn: + debug_fn(f"Daemon ready after {attempt + 1} attempts") + write_state( + DAEMON_STATE_FILE, + { + "port": port, + "started_by_plugin": True, + "started_at": time.time(), + "pid": os.getpid(), + }, + ) + return + time.sleep(1) + + raise RuntimeError("Daemon failed to become ready within 30 seconds") + + +def stop_daemon(config: dict, debug_fn=None): + """Stop the daemon if it was started by this plugin.""" + state = read_state(DAEMON_STATE_FILE) + if not state or not state.get("started_by_plugin"): + if debug_fn: + debug_fn("Daemon not started by plugin, skipping stop") + return + + if debug_fn: + debug_fn("Stopping daemon...") + + try: + result = _run_embed( + config, + ["daemon", "--profile", PROFILE_NAME, "stop"], + timeout=10, + ) + if debug_fn: + debug_fn(f"Daemon stop: {result.stdout.strip()}") + except Exception as e: + if debug_fn: + debug_fn(f"Daemon stop error: {e}") + + write_state(DAEMON_STATE_FILE, {}) diff --git a/hindsight-integrations/cursor/scripts/lib/llm.py b/hindsight-integrations/cursor/scripts/lib/llm.py new file mode 100644 index 000000000..c007955d6 --- /dev/null +++ b/hindsight-integrations/cursor/scripts/lib/llm.py @@ -0,0 +1,146 @@ +"""LLM provider detection for Hindsight's fact extraction. + +Port of: detectLLMConfig() in index.js + +When running hindsight-embed locally (daemon mode), it needs an LLM to +extract facts from retained conversations. This module detects the LLM +config using the same priority chain as Openclaw: + + 1. HINDSIGHT_API_LLM_* environment variables (highest priority) + 2. Plugin config (llmProvider, llmModel, llmApiKeyEnv) + 3. Auto-detect from standard provider env vars + 4. External API mode (server-side LLM, no local config needed) +""" + +import os + +# Provider detection table — same order as Openclaw +PROVIDER_DETECTION = [ + {"name": "openai", "key_env": "OPENAI_API_KEY"}, + {"name": "anthropic", "key_env": "ANTHROPIC_API_KEY"}, + {"name": "gemini", "key_env": "GEMINI_API_KEY"}, + {"name": "groq", "key_env": "GROQ_API_KEY"}, + {"name": "ollama", "key_env": ""}, + {"name": "openai-codex", "key_env": ""}, + {"name": "claude-code", "key_env": ""}, +] + +# Providers that don't require an API key +NO_KEY_REQUIRED = {"ollama", "openai-codex", "claude-code"} + + +def _find_provider(name): + """Find a provider entry by name.""" + for p in PROVIDER_DETECTION: + if p["name"] == name: + return p + return None + + +def detect_llm_config(config: dict) -> dict: + """Detect LLM configuration. + + Returns dict with: provider, api_key, model, base_url, source. + Returns None values for external API mode (server handles LLM). + Raises RuntimeError if no configuration found and not in external API mode. + """ + override_provider = os.environ.get("HINDSIGHT_API_LLM_PROVIDER") + override_model = os.environ.get("HINDSIGHT_API_LLM_MODEL") + override_key = os.environ.get("HINDSIGHT_API_LLM_API_KEY") + override_base_url = os.environ.get("HINDSIGHT_API_LLM_BASE_URL") + + # Priority 1: HINDSIGHT_API_LLM_PROVIDER env var + if override_provider: + if not override_key and override_provider not in NO_KEY_REQUIRED: + raise RuntimeError( + f'HINDSIGHT_API_LLM_PROVIDER is set to "{override_provider}" but HINDSIGHT_API_LLM_API_KEY is not set.' + ) + pinfo = _find_provider(override_provider) + return { + "provider": override_provider, + "api_key": override_key or "", + "model": override_model, + "base_url": override_base_url, + "source": "HINDSIGHT_API_LLM_PROVIDER override", + } + + # Priority 2: Plugin config llmProvider/llmModel + cfg_provider = config.get("llmProvider") + if cfg_provider: + pinfo = _find_provider(cfg_provider) + api_key = "" + key_env_name = config.get("llmApiKeyEnv") + if key_env_name: + api_key = os.environ.get(key_env_name, "") + elif pinfo and pinfo["key_env"]: + api_key = os.environ.get(pinfo["key_env"], "") + + if not api_key and cfg_provider not in NO_KEY_REQUIRED: + key_source = key_env_name or (pinfo["key_env"] if pinfo else "unknown") + raise RuntimeError( + f'Plugin config llmProvider is "{cfg_provider}" but no API key found. Expected env var: {key_source}' + ) + return { + "provider": cfg_provider, + "api_key": api_key, + "model": config.get("llmModel") or override_model, + "base_url": override_base_url, + "source": "plugin config", + } + + # Priority 3: Auto-detect from standard provider env vars + for pinfo in PROVIDER_DETECTION: + if pinfo["name"] in NO_KEY_REQUIRED: + continue # Must be explicitly requested + if not pinfo["key_env"]: + continue + api_key = os.environ.get(pinfo["key_env"], "") + if api_key: + return { + "provider": pinfo["name"], + "api_key": api_key, + "model": override_model, + "base_url": override_base_url, + "source": f"auto-detected from {pinfo['key_env']}", + } + + # Priority 4: External API mode — server handles LLM + if config.get("hindsightApiUrl"): + return { + "provider": None, + "api_key": None, + "model": None, + "base_url": None, + "source": "external-api-mode-no-llm", + } + + raise RuntimeError( + "No LLM configuration found for Hindsight.\n\n" + "Option 1: Set a standard provider API key (auto-detect):\n" + " export OPENAI_API_KEY=sk-your-key\n" + " export ANTHROPIC_API_KEY=your-key\n\n" + "Option 2: Override with Hindsight-specific env vars:\n" + " export HINDSIGHT_API_LLM_PROVIDER=openai\n" + " export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n\n" + "Option 3: Use an external Hindsight API (server-side LLM):\n" + " Set hindsightApiUrl in settings.json or HINDSIGHT_API_URL env var\n\n" + "The model will be selected automatically by Hindsight. To override: export HINDSIGHT_API_LLM_MODEL=your-model" + ) + + +def get_llm_env_vars(llm_config: dict) -> dict: + """Build environment variables for hindsight-embed daemon from LLM config. + + These are passed to the daemon subprocess so it knows which LLM to use + for fact extraction. + """ + env = {} + if llm_config.get("provider"): + env["HINDSIGHT_API_LLM_PROVIDER"] = llm_config["provider"] + if llm_config.get("api_key"): + env["HINDSIGHT_API_LLM_API_KEY"] = llm_config["api_key"] + if llm_config.get("model"): + env["HINDSIGHT_API_LLM_MODEL"] = llm_config["model"] + if llm_config.get("base_url"): + env["HINDSIGHT_API_LLM_BASE_URL"] = llm_config["base_url"] + return env diff --git a/hindsight-integrations/cursor/scripts/lib/state.py b/hindsight-integrations/cursor/scripts/lib/state.py new file mode 100644 index 000000000..2bc4bcb4c --- /dev/null +++ b/hindsight-integrations/cursor/scripts/lib/state.py @@ -0,0 +1,114 @@ +"""File-based state persistence for Cursor plugin. + +Cursor hooks are ephemeral processes -- state must be persisted to files. +Uses $CURSOR_PLUGIN_DATA/state/ or ~/.hindsight/cursor-state/ as storage. +""" + +import json +import os +import re +import sys + +# fcntl is Unix-only; import conditionally so the module loads on Windows +if sys.platform != "win32": + import fcntl +else: + fcntl = None + + +def _state_dir() -> str: + """Get the state directory, creating it if needed.""" + plugin_data = os.environ.get("CURSOR_PLUGIN_DATA", "") + if not plugin_data: + plugin_data = os.path.join(os.path.expanduser("~"), ".hindsight", "cursor-state") + state_dir = os.path.join(plugin_data, "state") + os.makedirs(state_dir, exist_ok=True) + return state_dir + + +def _safe_filename(name: str) -> str: + """Sanitize a filename to prevent path traversal.""" + name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", name) + name = name.replace("..", "_") + name = name[:200] + return name or "state" + + +def _state_file(name: str) -> str: + """Get path for a state file. Name is sanitized to prevent traversal.""" + safe = _safe_filename(name) + path = os.path.join(_state_dir(), safe) + resolved = os.path.realpath(path) + expected_dir = os.path.realpath(_state_dir()) + if not resolved.startswith(expected_dir + os.sep) and resolved != expected_dir: + raise ValueError(f"State file path escapes state directory: {name!r}") + return path + + +def read_state(name: str, default=None): + """Read a JSON state file. Returns default if not found.""" + path = _state_file(name) + if not os.path.exists(path): + return default + try: + with open(path) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return default + + +def write_state(name: str, data): + """Write data to a JSON state file atomically.""" + path = _state_file(name) + tmp_path = path + ".tmp" + try: + with open(tmp_path, "w") as f: + json.dump(data, f) + os.replace(tmp_path, path) + except OSError: + try: + os.unlink(tmp_path) + except OSError: + pass + + +def get_turn_count(session_id: str) -> int: + """Get the current turn count for a session.""" + turns = read_state("turns.json", {}) + return turns.get(session_id, 0) + + +def increment_turn_count(session_id: str) -> int: + """Increment and return the turn count for a session. + + Uses flock on Unix to prevent race conditions between concurrent hook + processes. On Windows, proceeds without a lock. + """ + lock_path = _state_file("turns.lock") + if fcntl is not None: + try: + lock_fd = open(lock_path, "w") + fcntl.flock(lock_fd, fcntl.LOCK_EX) + try: + turns = read_state("turns.json", {}) + turns[session_id] = turns.get(session_id, 0) + 1 + if len(turns) > 10000: + sorted_keys = sorted(turns.keys()) + for k in sorted_keys[: len(sorted_keys) // 2]: + del turns[k] + write_state("turns.json", turns) + return turns[session_id] + finally: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + lock_fd.close() + except OSError: + pass + + turns = read_state("turns.json", {}) + turns[session_id] = turns.get(session_id, 0) + 1 + if len(turns) > 10000: + sorted_keys = sorted(turns.keys()) + for k in sorted_keys[: len(sorted_keys) // 2]: + del turns[k] + write_state("turns.json", turns) + return turns[session_id] diff --git a/hindsight-integrations/cursor/scripts/recall.py b/hindsight-integrations/cursor/scripts/recall.py new file mode 100644 index 000000000..966718a68 --- /dev/null +++ b/hindsight-integrations/cursor/scripts/recall.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Auto-recall hook for Cursor's beforeSubmitPrompt event. + +Flow: + 1. Read hook input from stdin (prompt, conversation_id, cwd) + 2. Resolve API URL (external, existing local, or auto-start daemon) + 3. Derive bank ID (static or dynamic from project context) + 4. Ensure bank mission is set (first use only) + 5. Compose multi-turn query if recallContextTurns > 1 + 6. Truncate to recallMaxQueryChars + 7. Call Hindsight recall API + 8. Format memories and output additionalContext + 9. Save last recall to state + +Exit codes: + 0 -- always (graceful degradation on any error) +""" + +import json +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from lib.bank import derive_bank_id, ensure_bank_mission +from lib.client import HindsightClient +from lib.config import debug_log, load_config +from lib.content import ( + compose_recall_query, + format_current_time, + format_memories, + truncate_recall_query, +) +from lib.daemon import get_api_url +from lib.state import write_state + +LAST_RECALL_STATE = "last_recall.json" + + +def read_transcript_messages(transcript_path: str) -> list: + """Read messages from a JSONL transcript file for multi-turn context. + + Cursor transcript format: + {role: "user", content: "..."} + {role: "assistant", content: "..."} + """ + if not transcript_path or not os.path.isfile(transcript_path): + return [] + messages = [] + try: + with open(transcript_path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + if "role" in entry and "content" in entry: + messages.append(entry) + except json.JSONDecodeError: + continue + except OSError: + pass + return messages + + +def main(): + config = load_config() + + if not config.get("autoRecall"): + debug_log(config, "Auto-recall disabled, exiting") + return + + # Read hook input from stdin + try: + hook_input = json.load(sys.stdin) + except (json.JSONDecodeError, EOFError): + print("[Hindsight] Failed to read hook input", file=sys.stderr) + return + + debug_log(config, f"Hook input keys: {list(hook_input.keys())}") + + # Extract user query from Cursor's hook input + prompt = (hook_input.get("prompt") or hook_input.get("user_prompt") or "").strip() + if not prompt or len(prompt) < 5: + debug_log(config, "Prompt too short for recall, skipping") + return + + # Resolve API URL + def _dbg(*a): + debug_log(config, *a) + + try: + api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False) + except RuntimeError as e: + print(f"[Hindsight] {e}", file=sys.stderr) + return + + api_token = config.get("hindsightApiToken") + try: + client = HindsightClient(api_url, api_token) + except ValueError as e: + print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr) + return + + # Derive bank ID + bank_id = derive_bank_id(hook_input, config) + + # Set bank mission on first use + ensure_bank_mission(client, bank_id, config, debug_fn=_dbg) + + # Multi-turn query composition + recall_context_turns = config.get("recallContextTurns", 1) + recall_max_query_chars = config.get("recallMaxQueryChars", 800) + + if recall_context_turns > 1: + transcript_path = hook_input.get("transcript_path", "") + messages = read_transcript_messages(transcript_path) + debug_log(config, f"Multi-turn context: {recall_context_turns} turns, {len(messages)} messages") + query = compose_recall_query(prompt, messages, recall_context_turns) + else: + query = prompt + + query = truncate_recall_query(query, prompt, recall_max_query_chars) + + if len(query) > recall_max_query_chars: + query = query[:recall_max_query_chars] + + debug_log(config, f"Recalling from bank '{bank_id}', query length: {len(query)}") + + # Call Hindsight recall API + try: + response = client.recall( + bank_id=bank_id, + query=query, + max_tokens=config.get("recallMaxTokens", 1024), + budget=config.get("recallBudget", "mid"), + types=config.get("recallTypes"), + timeout=10, + ) + except Exception as e: + print(f"[Hindsight] Recall failed: {e}", file=sys.stderr) + return + + results = response.get("results", []) + if not results: + debug_log(config, "No memories found") + return + + debug_log(config, f"Injecting {len(results)} memories") + + # Format context message + memories_formatted = format_memories(results) + preamble = config.get("recallPromptPreamble", "") + current_time = format_current_time() + + context_message = ( + f"\n" + f"{preamble}\n" + f"Current time - {current_time}\n\n" + f"{memories_formatted}\n" + f"" + ) + + # Save last recall to state + write_state( + LAST_RECALL_STATE, + { + "context": context_message, + "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "bank_id": bank_id, + "result_count": len(results), + }, + ) + + # Output for Cursor hook system + output = { + "hookSpecificOutput": { + "hookEventName": "beforeSubmitPrompt", + "additionalContext": context_message, + } + } + json.dump(output, sys.stdout) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"[Hindsight] Unexpected error in recall: {e}", file=sys.stderr) + try: + from lib.config import load_config + + sys.exit(2 if load_config().get("debug") else 0) + except Exception: + sys.exit(0) diff --git a/hindsight-integrations/cursor/scripts/retain.py b/hindsight-integrations/cursor/scripts/retain.py new file mode 100644 index 000000000..09a70f915 --- /dev/null +++ b/hindsight-integrations/cursor/scripts/retain.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Auto-retain hook for Cursor's stop event. + +Flow: + 1. Read hook input from stdin (conversation_id, transcript_path, status) + 2. Read conversation transcript from transcript_path + 3. Apply retention logic (full-session or chunked with overlap) + 4. Resolve API URL (external, existing local, or auto-start daemon) + 5. Derive bank ID and ensure mission + 6. Format transcript (strip memory tags) + 7. POST to Hindsight retain API (async) + +Exit codes: + 0 -- always (graceful degradation on any error) +""" + +import json +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from lib.bank import derive_bank_id, ensure_bank_mission +from lib.client import HindsightClient +from lib.config import debug_log, load_config +from lib.content import ( + prepare_retention_transcript, + slice_last_turns_by_user_boundary, +) +from lib.daemon import get_api_url +from lib.state import increment_turn_count + + +def read_transcript(transcript_path: str) -> list: + """Read a JSONL transcript file and return list of message dicts. + + Supports both flat format and nested format: + Flat: {role: "user", content: "..."} + Nested: {type: "user", message: {role: "user", content: "..."}} + """ + if not transcript_path or not os.path.isfile(transcript_path): + return [] + messages = [] + try: + with open(transcript_path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + # Nested format + if entry.get("type") in ("user", "assistant"): + msg = entry.get("message", {}) + if isinstance(msg, dict) and msg.get("role"): + messages.append(msg) + # Flat format + elif "role" in entry and "content" in entry: + messages.append(entry) + except json.JSONDecodeError: + continue + except OSError: + pass + return messages + + +def main(): + config = load_config() + + if not config.get("autoRetain"): + debug_log(config, "Auto-retain disabled, exiting") + return + + # Read hook input from stdin + try: + hook_input = json.load(sys.stdin) + except (json.JSONDecodeError, EOFError): + print("[Hindsight] Failed to read hook input", file=sys.stderr) + return + + debug_log(config, f"Stop hook input keys: {list(hook_input.keys())}") + + # Cursor stop hook provides conversation_id and transcript_path + session_id = hook_input.get("conversation_id") or hook_input.get("session_id") or "unknown" + transcript_path = hook_input.get("transcript_path", "") + + # Read full transcript + all_messages = read_transcript(transcript_path) + if not all_messages: + debug_log(config, "No messages in transcript, skipping retain") + return + + debug_log(config, f"Read {len(all_messages)} messages from transcript") + + # Retention mode: full session (default) or chunked + retain_mode = config.get("retainMode", "full-session") + retain_every_n = max(1, config.get("retainEveryNTurns", 1)) + retain_full_window = False + messages_to_retain = all_messages + + if retain_every_n > 1: + turn_count = increment_turn_count(session_id) + if turn_count % retain_every_n != 0: + next_at = ((turn_count // retain_every_n) + 1) * retain_every_n + debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})") + return + + if retain_mode == "chunked" and retain_every_n > 1: + overlap_turns = config.get("retainOverlapTurns", 0) + window_turns = retain_every_n + overlap_turns + messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns) + retain_full_window = True + debug_log( + config, + f"Chunked retain firing (window: {window_turns} turns, {len(messages_to_retain)} messages)", + ) + else: + retain_full_window = True + debug_log(config, f"Full session retain: {len(all_messages)} messages") + + # Format transcript + include_tool_calls = config.get("retainToolCalls", False) + transcript, message_count = prepare_retention_transcript( + messages_to_retain, ["user", "assistant"], retain_full_window, include_tool_calls=include_tool_calls + ) + + if not transcript: + debug_log(config, "Empty transcript after formatting, skipping retain") + return + + # Resolve API URL + def _dbg(*a): + debug_log(config, *a) + + try: + api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=True) + except RuntimeError as e: + print(f"[Hindsight] {e}", file=sys.stderr) + return + + api_token = config.get("hindsightApiToken") + try: + client = HindsightClient(api_url, api_token) + except ValueError as e: + print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr) + return + + # Derive bank ID and ensure mission + bank_id = derive_bank_id(hook_input, config) + ensure_bank_mission(client, bank_id, config, debug_fn=_dbg) + + # Document ID: session_id for idempotent upserts, timestamped for chunks + if retain_mode == "chunked" and retain_every_n > 1: + document_id = f"{session_id}-{int(time.time() * 1000)}" + else: + document_id = session_id + + # Resolve template variables in tags and metadata + template_vars = { + "session_id": session_id, + "bank_id": bank_id, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + + def _resolve_template(value: str) -> str: + for k, v in template_vars.items(): + value = value.replace(f"{{{k}}}", v) + return value + + raw_tags = config.get("retainTags", []) + tags = [_resolve_template(t) for t in raw_tags] if raw_tags else None + + metadata = { + "retained_at": template_vars["timestamp"], + "message_count": str(message_count), + "session_id": session_id, + } + for k, v in config.get("retainMetadata", {}).items(): + metadata[k] = _resolve_template(str(v)) + + debug_log( + config, f"Retaining to bank '{bank_id}', doc '{document_id}', {message_count} messages, {len(transcript)} chars" + ) + + # POST to Hindsight retain API + try: + response = client.retain( + bank_id=bank_id, + content=transcript, + document_id=document_id, + context=config.get("retainContext", "cursor"), + metadata=metadata, + tags=tags, + timeout=15, + ) + debug_log(config, f"Retain response: {json.dumps(response)[:200]}") + except Exception as e: + print(f"[Hindsight] Retain failed: {e}", file=sys.stderr) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"[Hindsight] Unexpected error in retain: {e}", file=sys.stderr) + try: + from lib.config import load_config + + sys.exit(2 if load_config().get("debug") else 0) + except Exception: + sys.exit(0) diff --git a/hindsight-integrations/cursor/settings.json b/hindsight-integrations/cursor/settings.json new file mode 100644 index 000000000..3ef60a41a --- /dev/null +++ b/hindsight-integrations/cursor/settings.json @@ -0,0 +1,34 @@ +{ + "hindsightApiUrl": "", + "bankId": "cursor", + "bankMission": "You are a Cursor AI coding assistant. Focus on technical discussions, architectural decisions, code patterns, user preferences, and project context relevant to the user's development work.", + "retainMission": "Extract technical decisions, architectural choices, user preferences, project context, and tool/library relationships. Ignore routine greetings and transient operational details.", + "autoRecall": true, + "autoRetain": true, + "retainMode": "full-session", + "recallBudget": "mid", + "recallMaxTokens": 1024, + "recallTypes": ["world", "experience"], + "recallContextTurns": 1, + "recallMaxQueryChars": 800, + "recallPromptPreamble": "Relevant memories from past coding sessions (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:", + "retainEveryNTurns": 10, + "retainOverlapTurns": 2, + "retainToolCalls": false, + "retainTags": ["{session_id}"], + "retainMetadata": {}, + "retainContext": "cursor", + "hindsightApiToken": null, + "apiPort": 9077, + "daemonIdleTimeout": 0, + "embedVersion": "latest", + "embedPackagePath": null, + "bankIdPrefix": "", + "dynamicBankId": false, + "dynamicBankGranularity": ["agent", "project"], + "agentName": "cursor", + "llmProvider": null, + "llmModel": null, + "llmApiKeyEnv": null, + "debug": false +} diff --git a/hindsight-integrations/cursor/skills/hindsight-recall/SKILL.md b/hindsight-integrations/cursor/skills/hindsight-recall/SKILL.md new file mode 100644 index 000000000..d1704c7ad --- /dev/null +++ b/hindsight-integrations/cursor/skills/hindsight-recall/SKILL.md @@ -0,0 +1,29 @@ +--- +name: hindsight-recall +description: Search long-term memory for relevant context from past coding sessions using Hindsight +--- + +# Hindsight Recall + +## Trigger + +Use when the user asks about past decisions, project context, preferences, or anything that may have been discussed in prior sessions. Also useful when starting work on a codebase to recall relevant architectural decisions and patterns. + +## Workflow + +1. Identify the key topic or question from the user's request. +2. Use the Hindsight MCP `recall` tool to search for relevant memories. +3. If recall returns results, incorporate them naturally into your response. +4. If the user shares new important information (decisions, preferences, patterns), use the `retain` tool to store it. + +## Guardrails + +- Only recall when prior context would genuinely help answer the question. +- Do not recall for simple, self-contained coding questions. +- When memories conflict with current context, prefer current context and note the discrepancy. +- Do not expose raw memory metadata to the user unless asked. + +## Output + +- Relevant memories integrated into the response +- New information stored if the user shares durable knowledge diff --git a/hindsight-integrations/cursor/tests/__init__.py b/hindsight-integrations/cursor/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/hindsight-integrations/cursor/tests/conftest.py b/hindsight-integrations/cursor/tests/conftest.py new file mode 100644 index 000000000..10ef596fc --- /dev/null +++ b/hindsight-integrations/cursor/tests/conftest.py @@ -0,0 +1,7 @@ +"""Shared test fixtures for Cursor Hindsight plugin tests.""" + +import os +import sys + +# Ensure scripts/lib is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) diff --git a/hindsight-integrations/cursor/tests/test_bank.py b/hindsight-integrations/cursor/tests/test_bank.py new file mode 100644 index 000000000..6ffffe44b --- /dev/null +++ b/hindsight-integrations/cursor/tests/test_bank.py @@ -0,0 +1,90 @@ +"""Tests for Cursor plugin bank ID derivation.""" + +import os + +import pytest + +from lib.bank import derive_bank_id, DEFAULT_BANK_NAME + + +class TestDeriveBankId: + def test_static_mode_default(self): + config = {"dynamicBankId": False, "bankId": None, "bankIdPrefix": ""} + result = derive_bank_id({}, config) + assert result == DEFAULT_BANK_NAME + assert result == "cursor" + + def test_static_mode_custom(self): + config = {"dynamicBankId": False, "bankId": "my-project", "bankIdPrefix": ""} + result = derive_bank_id({}, config) + assert result == "my-project" + + def test_static_mode_with_prefix(self): + config = {"dynamicBankId": False, "bankId": "my-project", "bankIdPrefix": "org"} + result = derive_bank_id({}, config) + assert result == "org-my-project" + + def test_dynamic_mode_project(self): + config = { + "dynamicBankId": True, + "dynamicBankGranularity": ["project"], + "bankIdPrefix": "", + "agentName": "cursor", + } + hook_input = {"cwd": "/home/user/my-project"} + result = derive_bank_id(hook_input, config) + assert result == "my-project" + + def test_dynamic_mode_agent_project(self): + config = { + "dynamicBankId": True, + "dynamicBankGranularity": ["agent", "project"], + "bankIdPrefix": "", + "agentName": "cursor", + } + hook_input = {"cwd": "/home/user/my-project"} + result = derive_bank_id(hook_input, config) + assert result == "cursor::my-project" + + def test_dynamic_mode_with_prefix(self): + config = { + "dynamicBankId": True, + "dynamicBankGranularity": ["agent"], + "bankIdPrefix": "company", + "agentName": "cursor", + } + result = derive_bank_id({}, config) + assert result == "company-cursor" + + def test_dynamic_mode_unknown_field_warns(self, capsys): + config = { + "dynamicBankId": True, + "dynamicBankGranularity": ["agent", "invalid_field"], + "bankIdPrefix": "", + "agentName": "cursor", + } + derive_bank_id({}, config) + captured = capsys.readouterr() + assert "Unknown dynamicBankGranularity field" in captured.err + + def test_dynamic_mode_no_cwd(self): + config = { + "dynamicBankId": True, + "dynamicBankGranularity": ["project"], + "bankIdPrefix": "", + "agentName": "cursor", + } + result = derive_bank_id({}, config) + assert result == "unknown" + + def test_dynamic_mode_channel_user(self, monkeypatch): + monkeypatch.setenv("HINDSIGHT_CHANNEL_ID", "slack-general") + monkeypatch.setenv("HINDSIGHT_USER_ID", "user123") + config = { + "dynamicBankId": True, + "dynamicBankGranularity": ["channel", "user"], + "bankIdPrefix": "", + "agentName": "cursor", + } + result = derive_bank_id({}, config) + assert result == "slack-general::user123" diff --git a/hindsight-integrations/cursor/tests/test_config.py b/hindsight-integrations/cursor/tests/test_config.py new file mode 100644 index 000000000..319741224 --- /dev/null +++ b/hindsight-integrations/cursor/tests/test_config.py @@ -0,0 +1,87 @@ +"""Tests for Cursor plugin configuration loading.""" + +import os +import json +import tempfile + +import pytest + +from lib.config import load_config, DEFAULTS + + +class TestLoadConfig: + def test_returns_defaults_when_no_files(self, monkeypatch): + monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + config = load_config() + assert config["autoRecall"] is True + assert config["autoRetain"] is True + assert config["recallBudget"] == "mid" + assert config["retainContext"] == "cursor" + assert config["agentName"] == "cursor" + + def test_env_overrides_bool(self, monkeypatch): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_AUTO_RECALL", "false") + config = load_config() + assert config["autoRecall"] is False + + def test_env_overrides_int(self, monkeypatch): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_RECALL_MAX_TOKENS", "2048") + config = load_config() + assert config["recallMaxTokens"] == 2048 + + def test_env_overrides_string(self, monkeypatch): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_API_URL", "http://example.com") + config = load_config() + assert config["hindsightApiUrl"] == "http://example.com" + + def test_settings_file_loaded(self, monkeypatch, tmp_path): + settings = {"bankId": "test-bank", "debug": True} + settings_path = tmp_path / "settings.json" + settings_path.write_text(json.dumps(settings)) + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", str(tmp_path)) + config = load_config() + assert config["bankId"] == "test-bank" + assert config["debug"] is True + + def test_user_config_overrides_plugin_settings(self, monkeypatch, tmp_path): + # Plugin settings + plugin_settings = {"bankId": "plugin-bank", "debug": False} + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + (plugin_dir / "settings.json").write_text(json.dumps(plugin_settings)) + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", str(plugin_dir)) + + # User config + user_dir = tmp_path / "home" / ".hindsight" + user_dir.mkdir(parents=True) + user_config = {"bankId": "user-bank"} + (user_dir / "cursor.json").write_text(json.dumps(user_config)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + + config = load_config() + assert config["bankId"] == "user-bank" + + def test_env_overrides_user_config(self, monkeypatch, tmp_path): + user_dir = tmp_path / "home" / ".hindsight" + user_dir.mkdir(parents=True) + (user_dir / "cursor.json").write_text(json.dumps({"bankId": "user-bank"})) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_BANK_ID", "env-bank") + + config = load_config() + assert config["bankId"] == "env-bank" + + def test_default_agent_name_is_cursor(self, monkeypatch): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + config = load_config() + assert config["agentName"] == "cursor" + + def test_default_retain_context_is_cursor(self, monkeypatch): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + config = load_config() + assert config["retainContext"] == "cursor" diff --git a/hindsight-integrations/cursor/tests/test_content.py b/hindsight-integrations/cursor/tests/test_content.py new file mode 100644 index 000000000..287a37894 --- /dev/null +++ b/hindsight-integrations/cursor/tests/test_content.py @@ -0,0 +1,107 @@ +"""Tests for content processing utilities.""" + +import pytest + +from lib.content import ( + strip_memory_tags, + compose_recall_query, + truncate_recall_query, + slice_last_turns_by_user_boundary, + format_memories, + prepare_retention_transcript, +) + + +class TestStripMemoryTags: + def test_strips_hindsight_memories(self): + content = "Hello secret world" + assert strip_memory_tags(content) == "Hello world" + + def test_strips_relevant_memories(self): + content = "Before data after" + assert strip_memory_tags(content) == "Before after" + + def test_no_tags_unchanged(self): + content = "Just plain text" + assert strip_memory_tags(content) == "Just plain text" + + +class TestSliceLastTurns: + def test_returns_last_n_turns(self): + messages = [ + {"role": "user", "content": "Turn 1"}, + {"role": "assistant", "content": "Reply 1"}, + {"role": "user", "content": "Turn 2"}, + {"role": "assistant", "content": "Reply 2"}, + ] + result = slice_last_turns_by_user_boundary(messages, 1) + assert len(result) == 2 + assert result[0]["content"] == "Turn 2" + + def test_returns_all_if_fewer_turns(self): + messages = [ + {"role": "user", "content": "Only turn"}, + {"role": "assistant", "content": "Reply"}, + ] + result = slice_last_turns_by_user_boundary(messages, 5) + assert len(result) == 2 + + def test_empty_messages(self): + assert slice_last_turns_by_user_boundary([], 1) == [] + + +class TestComposeRecallQuery: + def test_single_turn(self): + result = compose_recall_query("What is X?", [], 1) + assert result == "What is X?" + + def test_multi_turn(self): + messages = [ + {"role": "user", "content": "First question"}, + {"role": "assistant", "content": "First answer"}, + {"role": "user", "content": "Follow up"}, + ] + result = compose_recall_query("Follow up", messages, 2) + assert "Prior context:" in result + assert "First question" in result + + +class TestFormatMemories: + def test_formats_with_type_and_date(self): + results = [ + {"text": "User likes Python", "type": "world", "mentioned_at": "2026-01-01"}, + ] + formatted = format_memories(results) + assert "User likes Python" in formatted + assert "[world]" in formatted + assert "(2026-01-01)" in formatted + + def test_empty_results(self): + assert format_memories([]) == "" + + +class TestPrepareRetentionTranscript: + def test_formats_user_assistant(self): + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + ] + transcript, count = prepare_retention_transcript(messages, ["user", "assistant"], True) + assert transcript is not None + assert count == 2 + assert "Hello" in transcript + assert "Hi there" in transcript + + def test_empty_messages(self): + transcript, count = prepare_retention_transcript([], ["user", "assistant"], True) + assert transcript is None + assert count == 0 + + def test_strips_memory_tags(self): + messages = [ + {"role": "user", "content": "Hello injected"}, + ] + transcript, count = prepare_retention_transcript(messages, ["user"], True) + assert transcript is not None + assert "hindsight_memories" not in transcript + assert "injected" not in transcript diff --git a/hindsight-integrations/cursor/tests/test_hooks.py b/hindsight-integrations/cursor/tests/test_hooks.py new file mode 100644 index 000000000..967988d7a --- /dev/null +++ b/hindsight-integrations/cursor/tests/test_hooks.py @@ -0,0 +1,185 @@ +"""Tests for Cursor plugin hook scripts (recall and retain).""" + +import importlib +import io +import json +import os +import sys +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +# Import the hook scripts as modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + + +class TestRecallHook: + def test_skips_when_auto_recall_disabled(self, monkeypatch, capsys): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_AUTO_RECALL", "false") + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"prompt": "test"}))) + + import recall + importlib.reload(recall) + recall.main() + + output = capsys.readouterr() + assert output.out == "" # No JSON output means no context injected + + def test_skips_short_prompt(self, monkeypatch, capsys): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_API_URL", "http://localhost:8888") + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"prompt": "hi"}))) + + import recall + importlib.reload(recall) + recall.main() + + output = capsys.readouterr() + assert output.out == "" + + def test_outputs_context_on_results(self, monkeypatch, capsys): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + + mock_client = MagicMock() + mock_client.recall.return_value = { + "results": [{"text": "User prefers TypeScript", "type": "world", "mentioned_at": "2026-01-01"}] + } + + hook_input = {"prompt": "What language should I use?", "cwd": "/tmp/test"} + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(hook_input))) + + import recall + importlib.reload(recall) + + with patch.object(recall, "get_api_url", return_value="http://localhost:8888"), \ + patch.object(recall, "HindsightClient", return_value=mock_client), \ + patch.object(recall, "ensure_bank_mission"), \ + patch.object(recall, "write_state"): + recall.main() + + output = capsys.readouterr() + result = json.loads(output.out) + assert "additionalContext" in result["hookSpecificOutput"] + assert "User prefers TypeScript" in result["hookSpecificOutput"]["additionalContext"] + assert "hindsight_memories" in result["hookSpecificOutput"]["additionalContext"] + + def test_no_output_on_empty_results(self, monkeypatch, capsys): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + + mock_client = MagicMock() + mock_client.recall.return_value = {"results": []} + + hook_input = {"prompt": "Tell me about quantum physics"} + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(hook_input))) + + import recall + importlib.reload(recall) + + with patch.object(recall, "get_api_url", return_value="http://localhost:8888"), \ + patch.object(recall, "HindsightClient", return_value=mock_client), \ + patch.object(recall, "ensure_bank_mission"): + recall.main() + + output = capsys.readouterr() + assert output.out == "" + + +class TestRetainHook: + def test_skips_when_auto_retain_disabled(self, monkeypatch, capsys): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_AUTO_RETAIN", "false") + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"conversation_id": "c1"}))) + + import retain + importlib.reload(retain) + retain.main() + + output = capsys.readouterr() + assert output.out == "" + + def test_skips_empty_transcript(self, monkeypatch, capsys): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_API_URL", "http://localhost:8888") + + hook_input = {"conversation_id": "c1", "transcript_path": "/nonexistent/transcript.jsonl"} + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(hook_input))) + + import retain + importlib.reload(retain) + retain.main() + + def test_retains_transcript(self, monkeypatch, tmp_path): + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/nonexistent") + monkeypatch.setenv("HINDSIGHT_RETAIN_EVERY_N_TURNS", "1") + + mock_client = MagicMock() + mock_client.retain.return_value = {"status": "ok"} + + # Write a test transcript + transcript_path = tmp_path / "transcript.jsonl" + messages = [ + {"role": "user", "content": "Build a React app"}, + {"role": "assistant", "content": "I'll create a React app for you."}, + ] + transcript_path.write_text("\n".join(json.dumps(m) for m in messages)) + + hook_input = { + "conversation_id": "conv-123", + "transcript_path": str(transcript_path), + "cwd": "/tmp/test", + } + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(hook_input))) + monkeypatch.setenv("CURSOR_PLUGIN_DATA", str(tmp_path / "data")) + + import retain + importlib.reload(retain) + + with patch.object(retain, "get_api_url", return_value="http://localhost:8888"), \ + patch.object(retain, "HindsightClient", return_value=mock_client), \ + patch.object(retain, "ensure_bank_mission"): + retain.main() + + mock_client.retain.assert_called_once() + call_kwargs = mock_client.retain.call_args + assert "bank_id" in call_kwargs[1] + assert call_kwargs[1]["context"] == "cursor" + + +class TestManifest: + def test_plugin_json_valid(self): + plugin_path = os.path.join( + os.path.dirname(__file__), "..", ".cursor-plugin", "plugin.json" + ) + with open(plugin_path) as f: + manifest = json.load(f) + + assert manifest["name"] == "hindsight-memory" + assert "description" in manifest + assert manifest["version"] + assert manifest["license"] == "MIT" + + def test_hooks_json_valid(self): + hooks_path = os.path.join( + os.path.dirname(__file__), "..", "hooks", "hooks.json" + ) + with open(hooks_path) as f: + hooks = json.load(f) + + assert hooks["version"] == 1 + assert "beforeSubmitPrompt" in hooks["hooks"] + assert "stop" in hooks["hooks"] + + def test_settings_json_valid(self): + settings_path = os.path.join( + os.path.dirname(__file__), "..", "settings.json" + ) + with open(settings_path) as f: + settings = json.load(f) + + assert settings["bankId"] == "cursor" + assert settings["retainContext"] == "cursor" + assert settings["agentName"] == "cursor" + assert "autoRecall" in settings + assert "autoRetain" in settings diff --git a/scripts/release-integration.sh b/scripts/release-integration.sh index d5251558b..de8a8f5f6 100755 --- a/scripts/release-integration.sh +++ b/scripts/release-integration.sh @@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; } print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } print_error() { echo -e "${RED}[ERROR]${NC} $1"; } -VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen") +VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen" "opencode" "cursor") usage() { print_error "Usage: $0 "