Merge main into thedotmack/file-read-timeline-inject
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
<claude-mem-context>
|
||||||
|
# claude-mem: Cross-Session Memory
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem's MCP search tools for manual memory queries.
|
||||||
|
</claude-mem-context>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "10.6.3",
|
"version": "11.0.0",
|
||||||
"source": "./plugin",
|
"source": "./plugin",
|
||||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<claude-mem-context>
|
||||||
|
# claude-mem: Cross-Session Memory
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem's MCP search tools for manual memory queries.
|
||||||
|
</claude-mem-context>
|
||||||
+4
-2
@@ -1,7 +1,6 @@
|
|||||||
datasets/
|
datasets/
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
!installer/dist/
|
|
||||||
**/_tree-sitter/
|
**/_tree-sitter/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -35,4 +34,7 @@ src/ui/viewer.html
|
|||||||
.claude-octopus/
|
.claude-octopus/
|
||||||
.claude/session-intent.md
|
.claude/session-intent.md
|
||||||
.claude/session-plan.md
|
.claude/session-plan.md
|
||||||
.octo/
|
.octo/
|
||||||
|
|
||||||
|
# Local contribution analysis (not part of upstream)
|
||||||
|
CONTRIB_NOTES.md
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# Source code (dist/ and plugin/ are the shipped artifacts)
|
||||||
|
src/
|
||||||
|
scripts/
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
datasets/
|
||||||
|
private/
|
||||||
|
antipattern-czar/
|
||||||
|
|
||||||
|
# Heavy binaries installed at runtime via smart-install.js
|
||||||
|
plugin/node_modules/
|
||||||
|
plugin/scripts/claude-mem
|
||||||
|
plugin/bun.lock
|
||||||
|
plugin/data/
|
||||||
|
plugin/data.backup/
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
*.ts
|
||||||
|
!*.d.ts
|
||||||
|
tsconfig*.json
|
||||||
|
.eslintrc*
|
||||||
|
.prettierrc*
|
||||||
|
.editorconfig
|
||||||
|
jest.config*
|
||||||
|
vitest.config*
|
||||||
|
|
||||||
|
# Git and CI
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.claude/
|
||||||
|
.cursor/
|
||||||
|
.mcp.json
|
||||||
|
.plan/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
Auto Run Docs/
|
||||||
|
~*/
|
||||||
|
http*/
|
||||||
|
https*/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem's MCP search tools for manual memory queries.
|
||||||
+4397
-64
File diff suppressed because it is too large
Load Diff
@@ -127,17 +127,29 @@
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Start a new Claude Code session in the terminal and enter the following commands:
|
Install with a single command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or install for Gemini CLI (auto-detects `~/.gemini`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install --ide gemini-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install from the plugin marketplace inside Claude Code:
|
||||||
|
|
||||||
|
```bash
|
||||||
/plugin marketplace add thedotmack/claude-mem
|
/plugin marketplace add thedotmack/claude-mem
|
||||||
|
|
||||||
/plugin install claude-mem
|
/plugin install claude-mem
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart Claude Code. Context from previous sessions will automatically appear in new sessions.
|
Restart Claude Code or Gemini CLI. Context from previous sessions will automatically appear in new sessions.
|
||||||
|
|
||||||
> **Note:** Claude-Mem is also published on npm, but `npm install -g claude-mem` installs the **SDK/library only** — it does not register the plugin hooks or set up the worker service. To use Claude-Mem as a plugin, always install via the `/plugin` commands above.
|
> **Note:** Claude-Mem is also published on npm, but `npm install -g claude-mem` installs the **SDK/library only** — it does not register the plugin hooks or set up the worker service. Always install via `npx claude-mem install` or the `/plugin` commands above.
|
||||||
|
|
||||||
### 🦞 OpenClaw Gateway
|
### 🦞 OpenClaw Gateway
|
||||||
|
|
||||||
@@ -171,6 +183,7 @@ The installer handles dependencies, plugin setup, AI provider configuration, wor
|
|||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
- **[Installation Guide](https://docs.claude-mem.ai/installation)** - Quick start & advanced installation
|
- **[Installation Guide](https://docs.claude-mem.ai/installation)** - Quick start & advanced installation
|
||||||
|
- **[Gemini CLI Setup](https://docs.claude-mem.ai/gemini-cli/setup)** - Dedicated guide for Google's Gemini CLI integration
|
||||||
- **[Usage Guide](https://docs.claude-mem.ai/usage/getting-started)** - How Claude-Mem works automatically
|
- **[Usage Guide](https://docs.claude-mem.ai/usage/getting-started)** - How Claude-Mem works automatically
|
||||||
- **[Search Tools](https://docs.claude-mem.ai/usage/search-tools)** - Query your project history with natural language
|
- **[Search Tools](https://docs.claude-mem.ai/usage/search-tools)** - Query your project history with natural language
|
||||||
- **[Beta Features](https://docs.claude-mem.ai/beta-features)** - Try experimental features like Endless Mode
|
- **[Beta Features](https://docs.claude-mem.ai/beta-features)** - Try experimental features like Endless Mode
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<claude-mem-context>
|
||||||
|
# claude-mem: Cross-Session Memory
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem's MCP search tools for manual memory queries.
|
||||||
|
</claude-mem-context>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# claude-mem Architecture Overview
|
||||||
|
|
||||||
|
## System Layers
|
||||||
|
|
||||||
|
```text
|
||||||
|
+-----------------------------------------------------------+
|
||||||
|
| Claude Code (host) |
|
||||||
|
| +-- Hook System (5 events) |
|
||||||
|
| +-- MCP Client (search tools) |
|
||||||
|
+-----------------------------------------------------------+
|
||||||
|
| CLI Layer (Bun) |
|
||||||
|
| +-- bun-runner.js (Node->Bun bridge) |
|
||||||
|
| +-- hook-command.ts (orchestrator) |
|
||||||
|
| +-- handlers/ (context, session-init, observation, |
|
||||||
|
| summarize, session-complete) |
|
||||||
|
+-----------------------------------------------------------+
|
||||||
|
| Worker Daemon (Express, port 37777) |
|
||||||
|
| +-- SessionManager (session lifecycle) |
|
||||||
|
| +-- SDKAgent (Claude Agent SDK) |
|
||||||
|
| +-- SearchManager (search orchestration) |
|
||||||
|
| +-- ProcessRegistry (subprocess management) |
|
||||||
|
| +-- ChromaSync (embedding synchronization) |
|
||||||
|
+-----------------------------------------------------------+
|
||||||
|
| Storage Layer |
|
||||||
|
| +-- SQLite (claude-mem.db) -- structured data |
|
||||||
|
| +-- ChromaDB (chroma.sqlite3) -- vector embeddings |
|
||||||
|
| +-- MCP Server (interface for Claude Code) |
|
||||||
|
+-----------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hook Lifecycle
|
||||||
|
|
||||||
|
| Event | Handler | What it does | Timeout |
|
||||||
|
|-------|---------|-------------|---------|
|
||||||
|
| Setup | setup.sh | Install system dependencies | 300s |
|
||||||
|
| SessionStart | smart-install.js + context | Install deps + start worker + inject context | 60s |
|
||||||
|
| UserPromptSubmit | session-init | Register session + start SDK agent + semantic injection | 60s |
|
||||||
|
| PostToolUse | observation | Capture tool usage -> enqueue in worker | 120s |
|
||||||
|
| Summary | summarize | Request session summary from SDK agent | 120s |
|
||||||
|
| SessionEnd | session-complete | End session + drain pending messages | 30s |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
User prompt -> session-init -> /api/sessions/init + /api/context/semantic
|
||||||
|
|
|
||||||
|
Tool use -> observation -> /api/sessions/observations
|
||||||
|
| |
|
||||||
|
| PendingMessageStore.enqueue()
|
||||||
|
| |
|
||||||
|
| SDKAgent.startSession()
|
||||||
|
| |
|
||||||
|
| Claude Agent SDK -> ResponseProcessor
|
||||||
|
| |
|
||||||
|
| +-- storeObservations() -> SQLite
|
||||||
|
| +-- chromaSync.sync() -> ChromaDB
|
||||||
|
| +-- broadcastObservation() -> SSE/UI
|
||||||
|
|
|
||||||
|
Stop -> summarize -> /api/sessions/summarize
|
||||||
|
-> session-complete -> /api/sessions/complete + drain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### CLAIM-CONFIRM (PendingMessageStore)
|
||||||
|
|
||||||
|
```text
|
||||||
|
enqueue() -> INSERT status='pending'
|
||||||
|
claimNextMessage() -> UPDATE status='processing' (atomic)
|
||||||
|
confirmProcessed() -> DELETE (success)
|
||||||
|
markFailed() -> UPDATE status='failed' (retry < 3)
|
||||||
|
|
||||||
|
Self-healing: messages in 'processing' for >60s reset to 'pending'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Circuit-Breaker (SessionRoutes)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Generator crash -> retry 1 (1s) -> retry 2 (2s) -> retry 3 (4s)
|
||||||
|
-> consecutiveRestarts > 3 -> CIRCUIT-BREAKER
|
||||||
|
-> markAllSessionMessagesAbandoned(sessionDbId)
|
||||||
|
-> Stop. No infinite loop.
|
||||||
|
```
|
||||||
|
|
||||||
|
Counter resets to 0 when generator completes work naturally.
|
||||||
|
|
||||||
|
### Graceful Degradation (hook-command.ts)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Transport errors (ECONNREFUSED, timeout, 5xx) -> exit 0 (never block Claude Code)
|
||||||
|
Client bugs (4xx, TypeError, ReferenceError) -> exit 2 (blocking, needs fix)
|
||||||
|
```
|
||||||
|
|
||||||
|
The worker being unavailable NEVER blocks the user's Claude Code session.
|
||||||
|
|
||||||
|
### Deduplication (observations)
|
||||||
|
|
||||||
|
```text
|
||||||
|
SHA256(memory_session_id + title + narrative)[:16] -> content_hash (16 hex chars)
|
||||||
|
If hash exists within 30s window -> return existing ID (no insert)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two Types of Session ID
|
||||||
|
|
||||||
|
- `contentSessionId` — from Claude Code, invariant during the session
|
||||||
|
- `memorySessionId` — from SDK Agent, changes on each worker restart
|
||||||
|
|
||||||
|
The conversion between them is handled by SessionStore and is critical for FK constraints.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
### SQLite (claude-mem.db)
|
||||||
|
|
||||||
|
| Table | Key fields | Purpose |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| sdk_sessions | content_session_id, memory_session_id, status | Session lifecycle |
|
||||||
|
| observations | memory_session_id, type, title, narrative, content_hash | Tool usage observations |
|
||||||
|
| session_summaries | memory_session_id, request, learned, completed | Session summaries |
|
||||||
|
| user_prompts | content_session_id, prompt_text | User prompt history |
|
||||||
|
| pending_messages | session_db_id, status, message_type | CLAIM-CONFIRM queue |
|
||||||
|
| observation_feedback | observation_id, signal_type | Usage tracking |
|
||||||
|
|
||||||
|
### ChromaDB (chroma.sqlite3)
|
||||||
|
|
||||||
|
Vector embeddings for semantic search. Each observation generates multiple documents:
|
||||||
|
|
||||||
|
```text
|
||||||
|
obs_{id}_narrative -> main text
|
||||||
|
obs_{id}_fact_0 -> first fact
|
||||||
|
obs_{id}_fact_1 -> second fact
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Accessed via chroma-mcp (MCP process), communication over stdio.
|
||||||
|
|
||||||
|
## Process Management
|
||||||
|
|
||||||
|
- **ProcessRegistry:** Tracks all Claude SDK subprocesses, manages PID lifecycle
|
||||||
|
- **Orphan Reaper (5min):** Kills processes with no active session
|
||||||
|
- **GracefulShutdown:** 7-step shutdown (PID file, children, HTTP server, sessions, MCP, DB, force-kill)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# claude-mem Production Guide
|
||||||
|
|
||||||
|
Practical guide based on 23 days of production usage with 3,400+ observations across two physical servers and 8 projects.
|
||||||
|
|
||||||
|
## Recommended Settings
|
||||||
|
|
||||||
|
| Setting | Default | Recommended | Why |
|
||||||
|
|---------|---------|-------------|-----|
|
||||||
|
| CLAUDE_MEM_MAX_CONCURRENT_AGENTS | 2 | 3 | Better throughput without overload |
|
||||||
|
| CLAUDE_MEM_SEMANTIC_INJECT | true | true | Relevant context >> recent context |
|
||||||
|
| CLAUDE_MEM_SEMANTIC_INJECT_LIMIT | 5 | 5 | Sweet spot for token cost vs coverage |
|
||||||
|
| CLAUDE_MEM_TIER_ROUTING_ENABLED | true | true | ~52% cost savings, no quality loss |
|
||||||
|
|
||||||
|
## Health Monitoring
|
||||||
|
|
||||||
|
### Key metrics to watch
|
||||||
|
|
||||||
|
| Metric | Healthy | Warning | Action |
|
||||||
|
|--------|---------|---------|--------|
|
||||||
|
| pending_messages (pending) | 0-5 | >10 | Check worker logs, may need restart |
|
||||||
|
| pending_messages (failed) | 0 | >0 growing | Circuit-breaker may be tripping |
|
||||||
|
| sdk_sessions (active) | 0-3 | >5 stuck | Orphan sessions, worker restart |
|
||||||
|
| WAL size | <10 MB | >20 MB | Run `PRAGMA wal_checkpoint(TRUNCATE)` |
|
||||||
|
| Chroma size | Growing slowly | Sudden jump | Check for sync loops |
|
||||||
|
| Errors/day in logs | 0-2 | >10 | Investigate log patterns |
|
||||||
|
|
||||||
|
### Quick health check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check worker status
|
||||||
|
curl -s http://127.0.0.1:37777/api/health | python3 -m json.tool
|
||||||
|
|
||||||
|
# Check database stats
|
||||||
|
sqlite3 ~/.claude-mem/claude-mem.db "
|
||||||
|
SELECT 'observations' as metric, COUNT(*) as value FROM observations
|
||||||
|
UNION ALL SELECT 'summaries', COUNT(*) FROM session_summaries
|
||||||
|
UNION ALL SELECT 'pending', COUNT(*) FROM pending_messages WHERE status='pending'
|
||||||
|
UNION ALL SELECT 'active_sessions', COUNT(*) FROM sdk_sessions WHERE status='active';
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Machine Setup
|
||||||
|
|
||||||
|
If running claude-mem on multiple machines, use `claude-mem-sync` to keep observations in sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude-mem-sync push <remote-host> # local -> remote
|
||||||
|
claude-mem-sync pull <remote-host> # remote -> local
|
||||||
|
claude-mem-sync sync <remote-host> # bidirectional
|
||||||
|
claude-mem-sync status <remote-host> # compare counts
|
||||||
|
```
|
||||||
|
|
||||||
|
Deduplication is by `(created_at, title)` — safe to run repeatedly.
|
||||||
|
|
||||||
|
## Growth Expectations
|
||||||
|
|
||||||
|
Based on active daily development usage:
|
||||||
|
|
||||||
|
| Metric | Per day | Per month | Notes |
|
||||||
|
|--------|---------|-----------|-------|
|
||||||
|
| Observations | ~120 | ~3,600 | Varies with coding activity |
|
||||||
|
| Summaries | ~40 | ~1,200 | One per session |
|
||||||
|
| SQLite | ~0.8 MB | ~24 MB | ~5 KB per observation |
|
||||||
|
| Chroma | ~4 MB | ~120 MB | ~50 KB per observation (embeddings) |
|
||||||
|
|
||||||
|
## Common Issues and Solutions
|
||||||
|
|
||||||
|
### Summarize error loop
|
||||||
|
|
||||||
|
**Symptom:** Repeated `[ERROR] Missing last_assistant_message` in logs.
|
||||||
|
**Cause:** Transcript with no assistant messages triggers summary attempt that fails repeatedly.
|
||||||
|
**Fix:** PR #1566 — skip summary when transcript is empty.
|
||||||
|
|
||||||
|
### Chroma sync failures
|
||||||
|
|
||||||
|
**Symptom:** `[ERROR] Batch add failed... IDs already exist`
|
||||||
|
**Cause:** MCP timeout during add leaves partial writes; retry fails on existing IDs.
|
||||||
|
**Fix:** PR #1566 — fallback to delete+add reconciliation.
|
||||||
|
|
||||||
|
### Port conflict on startup
|
||||||
|
|
||||||
|
**Symptom:** `Worker failed to start... Is port 37777 in use?`
|
||||||
|
**Cause:** Two sessions starting simultaneously — HTTP check is non-atomic (TOCTOU race).
|
||||||
|
**Fix:** PR #1566 — atomic socket bind on Unix.
|
||||||
|
|
||||||
|
### Orphaned pending messages
|
||||||
|
|
||||||
|
**Symptom:** `pending_messages` table growing with old entries for completed sessions.
|
||||||
|
**Cause:** SIGTERM kills generator before queue is drained.
|
||||||
|
**Fix:** PR #1567 — drain after deleteSession().
|
||||||
|
|
||||||
|
### Context not relevant to current topic
|
||||||
|
|
||||||
|
**Symptom:** Claude receives observations about CSS when you're asking about authentication.
|
||||||
|
**Cause:** Default recency-based injection selects most recent, not most relevant.
|
||||||
|
**Fix:** PR #1568 — semantic injection via Chroma on every prompt.
|
||||||
|
|
||||||
|
## Log Analysis Tips
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Count errors by day
|
||||||
|
grep '\[ERROR\]' ~/.claude-mem/logs/claude-mem-*.log | \
|
||||||
|
sed 's/\[20[0-9][0-9]-[0-9][0-9]-/\n&/g' | \
|
||||||
|
grep -oP '^\[20\d{2}-\d{2}-\d{2}' | sort | uniq -c
|
||||||
|
|
||||||
|
# Find circuit-breaker trips
|
||||||
|
grep 'circuit\|Circuit\|ABANDONED\|abandoned' ~/.claude-mem/logs/claude-mem-*.log
|
||||||
|
|
||||||
|
# Check pending message health
|
||||||
|
grep 'CLAIMED\|CONFIRMED\|FAILED\|ABANDONED' ~/.claude-mem/logs/claude-mem-$(date +%Y-%m-%d).log | tail -20
|
||||||
|
```
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"cursor/openrouter-setup"
|
"cursor/openrouter-setup"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"group": "Gemini CLI Integration",
|
||||||
|
"icon": "terminal",
|
||||||
|
"pages": [
|
||||||
|
"gemini-cli/setup"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"group": "Best Practices",
|
"group": "Best Practices",
|
||||||
"icon": "lightbulb",
|
"icon": "lightbulb",
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
---
|
||||||
|
title: "Gemini CLI Setup"
|
||||||
|
description: "Add persistent memory to Gemini CLI with claude-mem"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gemini CLI Setup
|
||||||
|
|
||||||
|
> **Give Gemini CLI persistent memory across sessions.**
|
||||||
|
|
||||||
|
Gemini CLI starts every session from scratch. Claude-mem changes that by capturing observations, decisions, and patterns — then injecting relevant context into each new session.
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
**How it works:** Claude-mem installs lifecycle hooks into Gemini CLI that capture tool usage, agent responses, and session events. A local worker service extracts semantic observations and injects relevant history at session start.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed and configured
|
||||||
|
- [Node.js](https://nodejs.org/) 18+
|
||||||
|
- The `~/.gemini` directory must exist (created by Gemini CLI on first run)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Step 1: Install claude-mem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer will:
|
||||||
|
1. Auto-detect Gemini CLI (checks for `~/.gemini` directory)
|
||||||
|
2. Prompt you to select **Gemini CLI** from the IDE picker
|
||||||
|
3. Install 8 lifecycle hooks into `~/.gemini/settings.json`
|
||||||
|
4. Inject context configuration into `~/.gemini/GEMINI.md`
|
||||||
|
5. Start the worker service
|
||||||
|
|
||||||
|
### Step 2: Configure an AI provider
|
||||||
|
|
||||||
|
Claude-mem needs an AI provider to extract observations from your sessions. Choose one:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Gemini API (Free)">
|
||||||
|
The simplest option — use Gemini's own API for observation extraction:
|
||||||
|
|
||||||
|
1. Get a free API key from [Google AI Studio](https://aistudio.google.com/apikey)
|
||||||
|
2. Add it to your settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude-mem
|
||||||
|
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||||
|
{
|
||||||
|
"CLAUDE_MEM_PROVIDER": "gemini",
|
||||||
|
"CLAUDE_MEM_GEMINI_API_KEY": "YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
**Free tier:** 1,500 requests/day with `gemini-2.5-flash-lite`. Enable billing on Google Cloud for 4,000 RPM without charges.
|
||||||
|
</Tip>
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Claude SDK">
|
||||||
|
If you have a Claude API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude-mem
|
||||||
|
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||||
|
{
|
||||||
|
"CLAUDE_MEM_PROVIDER": "claude"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Set your API key via environment variable:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_API_KEY="your-key"
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab title="OpenRouter">
|
||||||
|
For access to 100+ models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude-mem
|
||||||
|
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||||
|
{
|
||||||
|
"CLAUDE_MEM_PROVIDER": "openrouter",
|
||||||
|
"CLAUDE_MEM_OPENROUTER_API_KEY": "YOUR_KEY"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
### Step 3: Verify installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check worker is running
|
||||||
|
npx claude-mem status
|
||||||
|
|
||||||
|
# Check hooks are installed — look for claude-mem entries
|
||||||
|
cat ~/.gemini/settings.json | grep claude-mem
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:37777 to see the memory viewer.
|
||||||
|
|
||||||
|
### Step 4: Start using Gemini CLI
|
||||||
|
|
||||||
|
Launch Gemini CLI normally. Claude-mem works in the background:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini
|
||||||
|
```
|
||||||
|
|
||||||
|
On session start, you'll see claude-mem context injected with your recent observations and project history.
|
||||||
|
|
||||||
|
## What gets captured
|
||||||
|
|
||||||
|
Claude-mem registers 8 of Gemini CLI's 11 lifecycle hooks:
|
||||||
|
|
||||||
|
| Hook | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **SessionStart** | Injects memory context into the session |
|
||||||
|
| **SessionEnd** | Marks session complete, triggers summary |
|
||||||
|
| **PreCompress** | Captures session summary before compression |
|
||||||
|
| **Notification** | Records system events (permissions, etc.) |
|
||||||
|
| **BeforeAgent** | Captures user prompts |
|
||||||
|
| **AfterAgent** | Records full agent responses |
|
||||||
|
| **BeforeTool** | Logs tool invocations before execution |
|
||||||
|
| **AfterTool** | Captures tool results after execution |
|
||||||
|
|
||||||
|
Three model-level hooks (BeforeModel, AfterModel, BeforeToolSelection) are intentionally skipped — they fire per-LLM-call and are too noisy for memory capture.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Hooks not firing
|
||||||
|
|
||||||
|
1. Verify hooks exist in settings:
|
||||||
|
```bash
|
||||||
|
cat ~/.gemini/settings.json
|
||||||
|
```
|
||||||
|
You should see entries like `"SessionStart"`, `"AfterTool"`, etc. with claude-mem commands.
|
||||||
|
|
||||||
|
2. Restart Gemini CLI after installation.
|
||||||
|
|
||||||
|
3. Re-run the installer:
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Worker not running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check status
|
||||||
|
npx claude-mem status
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
npx claude-mem logs
|
||||||
|
|
||||||
|
# Restart worker
|
||||||
|
npx claude-mem restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### No context appearing at session start
|
||||||
|
|
||||||
|
1. Ensure the worker is running (check http://localhost:37777)
|
||||||
|
2. You need at least one previous session with observations for context to appear
|
||||||
|
3. Check your AI provider is configured in `~/.claude-mem/settings.json`
|
||||||
|
|
||||||
|
### Raw escape codes in output
|
||||||
|
|
||||||
|
If you see characters like `[31m` or `[0m` in the session context, your claude-mem version may need updating:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
This was fixed in v10.6.3+ — the Gemini CLI adapter now strips ANSI color codes automatically.
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes hooks from `~/.gemini/settings.json` and cleans up `~/.gemini/GEMINI.md`.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Gemini Provider](/usage/gemini-provider) — Configure the Gemini AI provider for observation extraction
|
||||||
|
- [Configuration](/configuration) — All settings options
|
||||||
|
- [Search Tools](/usage/search-tools) — Search your memory from within sessions
|
||||||
|
- [Troubleshooting](/troubleshooting) — Common issues and solutions
|
||||||
@@ -7,24 +7,35 @@ description: "Install Claude-Mem plugin for persistent memory across sessions"
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Install Claude-Mem directly from the plugin marketplace:
|
### Option 1: npx (Recommended)
|
||||||
|
|
||||||
|
Install and configure Claude-Mem with a single command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
The interactive installer will:
|
||||||
|
- Detect your installed IDEs (Claude Code, Cursor, Gemini CLI, Windsurf, etc.)
|
||||||
|
- Copy plugin files to the correct locations
|
||||||
|
- Register the plugin with Claude Code
|
||||||
|
- Install all dependencies (including Bun and uv)
|
||||||
|
- Auto-start the worker service
|
||||||
|
|
||||||
|
### Option 2: Plugin Marketplace
|
||||||
|
|
||||||
|
Install Claude-Mem directly from the plugin marketplace inside Claude Code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add thedotmack/claude-mem
|
/plugin marketplace add thedotmack/claude-mem
|
||||||
/plugin install claude-mem
|
/plugin install claude-mem
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it! The plugin will automatically:
|
Both methods will automatically configure hooks and start the worker service. Start a new Claude Code session and you'll see context from previous sessions automatically loaded.
|
||||||
- Download prebuilt binaries (no compilation needed)
|
|
||||||
- Install all dependencies (including SQLite binaries)
|
|
||||||
- Configure hooks for session lifecycle management
|
|
||||||
- Auto-start the worker service on first session
|
|
||||||
|
|
||||||
Start a new Claude Code session and you'll see context from previous sessions automatically loaded.
|
|
||||||
|
|
||||||
> **Important:** Claude-Mem is published on npm, but running `npm install -g claude-mem` installs the
|
> **Important:** Claude-Mem is published on npm, but running `npm install -g claude-mem` installs the
|
||||||
> **SDK/library only**. It does **not** register plugin hooks or start the worker service.
|
> **SDK/library only**. It does **not** register plugin hooks or start the worker service.
|
||||||
> To use Claude-Mem as a persistent memory plugin, always install via the `/plugin` commands above.
|
> Always install via `npx claude-mem install` or the `/plugin` commands above.
|
||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ Claude-Mem seamlessly preserves context across sessions by automatically capturi
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Start a new Claude Code session in the terminal and enter the following commands:
|
Install with a single command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install from the plugin marketplace inside Claude Code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add thedotmack/claude-mem
|
/plugin marketplace add thedotmack/claude-mem
|
||||||
|
|||||||
+15
-49
@@ -1,59 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# claude-mem installer bootstrap
|
# claude-mem installer redirect
|
||||||
# Usage: curl -fsSL https://install.cmem.ai | bash
|
# The old curl-pipe-bash installer has been replaced by npx claude-mem.
|
||||||
# or: curl -fsSL https://install.cmem.ai | bash -s -- --provider=gemini --api-key=YOUR_KEY
|
# This script now redirects users to the new install method.
|
||||||
|
|
||||||
INSTALLER_URL="https://install.cmem.ai/installer.js"
|
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
error() { echo -e "${RED}Error: $1${NC}" >&2; exit 1; }
|
echo ""
|
||||||
info() { echo -e "${CYAN}$1${NC}"; }
|
echo -e "${YELLOW}The curl-pipe-bash installer has been replaced.${NC}"
|
||||||
|
echo ""
|
||||||
# Check Node.js
|
echo -e "${GREEN}Install claude-mem with a single command:${NC}"
|
||||||
if ! command -v node &> /dev/null; then
|
echo ""
|
||||||
error "Node.js is required but not found. Install from https://nodejs.org"
|
echo -e " ${CYAN}npx claude-mem install${NC}"
|
||||||
fi
|
echo ""
|
||||||
|
echo -e "This requires Node.js >= 18. Get it from ${CYAN}https://nodejs.org${NC}"
|
||||||
NODE_VERSION=$(node -v | sed 's/v//')
|
echo ""
|
||||||
NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1)
|
echo -e "For more info, visit: ${CYAN}https://docs.claude-mem.ai/installation${NC}"
|
||||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
echo ""
|
||||||
error "Node.js >= 18 required. Current: v${NODE_VERSION}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "claude-mem installer (Node.js v${NODE_VERSION})"
|
|
||||||
|
|
||||||
# Create temp file for installer
|
|
||||||
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/claude-mem-installer.XXXXXX.mjs")
|
|
||||||
|
|
||||||
# Cleanup on exit
|
|
||||||
cleanup() {
|
|
||||||
rm -f "$TMPFILE"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
# Download installer
|
|
||||||
info "Downloading installer..."
|
|
||||||
if command -v curl &> /dev/null; then
|
|
||||||
curl -fsSL "$INSTALLER_URL" -o "$TMPFILE"
|
|
||||||
elif command -v wget &> /dev/null; then
|
|
||||||
wget -q "$INSTALLER_URL" -O "$TMPFILE"
|
|
||||||
else
|
|
||||||
error "curl or wget required to download installer"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run installer with TTY access
|
|
||||||
# When piped (curl | bash), stdin is the script. We need to reconnect to the terminal.
|
|
||||||
if [ -t 0 ]; then
|
|
||||||
# Already have TTY (script was downloaded and run directly)
|
|
||||||
node "$TMPFILE" "$@"
|
|
||||||
else
|
|
||||||
# Piped execution -- reconnect stdin to terminal
|
|
||||||
node "$TMPFILE" "$@" </dev/tty
|
|
||||||
fi
|
|
||||||
|
|||||||
+13
-2103
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
|||||||
import { build } from 'esbuild';
|
|
||||||
|
|
||||||
await build({
|
|
||||||
entryPoints: ['src/index.ts'],
|
|
||||||
bundle: true,
|
|
||||||
format: 'esm',
|
|
||||||
platform: 'node',
|
|
||||||
target: 'node18',
|
|
||||||
outfile: 'dist/index.js',
|
|
||||||
banner: {
|
|
||||||
js: '#!/usr/bin/env node',
|
|
||||||
},
|
|
||||||
external: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Build complete: dist/index.js');
|
|
||||||
Vendored
-2107
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "claude-mem-installer",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"bin": { "claude-mem-installer": "./dist/index.js" },
|
|
||||||
"files": ["dist"],
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build.mjs",
|
|
||||||
"dev": "node build.mjs && node dist/index.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@clack/prompts": "^1.0.1",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "^0.24.0",
|
|
||||||
"typescript": "^5.7.0",
|
|
||||||
"@types/node": "^22.0.0"
|
|
||||||
},
|
|
||||||
"engines": { "node": ">=18.0.0" }
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import { runWelcome } from './steps/welcome.js';
|
|
||||||
import { runDependencyChecks } from './steps/dependencies.js';
|
|
||||||
import { runIdeSelection } from './steps/ide-selection.js';
|
|
||||||
import { runProviderConfiguration } from './steps/provider.js';
|
|
||||||
import { runSettingsConfiguration } from './steps/settings.js';
|
|
||||||
import { writeSettings } from './utils/settings-writer.js';
|
|
||||||
import { runInstallation } from './steps/install.js';
|
|
||||||
import { runWorkerStartup } from './steps/worker.js';
|
|
||||||
import { runCompletion } from './steps/complete.js';
|
|
||||||
|
|
||||||
async function runInstaller(): Promise<void> {
|
|
||||||
if (!process.stdin.isTTY) {
|
|
||||||
console.error('Error: This installer requires an interactive terminal.');
|
|
||||||
console.error('Run directly: npx claude-mem-installer');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const installMode = await runWelcome();
|
|
||||||
|
|
||||||
// Dependency checks (all modes)
|
|
||||||
await runDependencyChecks();
|
|
||||||
|
|
||||||
// IDE and provider selection
|
|
||||||
const selectedIDEs = await runIdeSelection();
|
|
||||||
const providerConfig = await runProviderConfiguration();
|
|
||||||
|
|
||||||
// Settings configuration
|
|
||||||
const settingsConfig = await runSettingsConfiguration();
|
|
||||||
|
|
||||||
// Write settings file
|
|
||||||
writeSettings(providerConfig, settingsConfig);
|
|
||||||
p.log.success('Settings saved.');
|
|
||||||
|
|
||||||
// Installation (fresh or upgrade)
|
|
||||||
if (installMode !== 'configure') {
|
|
||||||
await runInstallation(selectedIDEs);
|
|
||||||
await runWorkerStartup(settingsConfig.workerPort, settingsConfig.dataDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Completion summary
|
|
||||||
runCompletion(providerConfig, settingsConfig, selectedIDEs);
|
|
||||||
}
|
|
||||||
|
|
||||||
runInstaller().catch((error) => {
|
|
||||||
p.cancel('Installation failed.');
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import type { ProviderConfig } from './provider.js';
|
|
||||||
import type { SettingsConfig } from './settings.js';
|
|
||||||
import type { IDE } from './ide-selection.js';
|
|
||||||
|
|
||||||
function getProviderLabel(config: ProviderConfig): string {
|
|
||||||
switch (config.provider) {
|
|
||||||
case 'claude':
|
|
||||||
return config.claudeAuthMethod === 'api' ? 'Claude (API Key)' : 'Claude (CLI subscription)';
|
|
||||||
case 'gemini':
|
|
||||||
return `Gemini (${config.model ?? 'gemini-2.5-flash-lite'})`;
|
|
||||||
case 'openrouter':
|
|
||||||
return `OpenRouter (${config.model ?? 'xiaomi/mimo-v2-flash:free'})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIDELabels(ides: IDE[]): string {
|
|
||||||
return ides.map((ide) => {
|
|
||||||
switch (ide) {
|
|
||||||
case 'claude-code': return 'Claude Code';
|
|
||||||
case 'cursor': return 'Cursor';
|
|
||||||
}
|
|
||||||
}).join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runCompletion(
|
|
||||||
providerConfig: ProviderConfig,
|
|
||||||
settingsConfig: SettingsConfig,
|
|
||||||
selectedIDEs: IDE[],
|
|
||||||
): void {
|
|
||||||
const summaryLines = [
|
|
||||||
`Provider: ${pc.cyan(getProviderLabel(providerConfig))}`,
|
|
||||||
`IDEs: ${pc.cyan(getIDELabels(selectedIDEs))}`,
|
|
||||||
`Data dir: ${pc.cyan(settingsConfig.dataDir)}`,
|
|
||||||
`Port: ${pc.cyan(settingsConfig.workerPort)}`,
|
|
||||||
`Chroma: ${settingsConfig.chromaEnabled ? pc.green('enabled') : pc.dim('disabled')}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
p.note(summaryLines.join('\n'), 'Configuration Summary');
|
|
||||||
|
|
||||||
const nextStepsLines: string[] = [];
|
|
||||||
|
|
||||||
if (selectedIDEs.includes('claude-code')) {
|
|
||||||
nextStepsLines.push('Open Claude Code and start a conversation — memory is automatic!');
|
|
||||||
}
|
|
||||||
if (selectedIDEs.includes('cursor')) {
|
|
||||||
nextStepsLines.push('Open Cursor — hooks are active in your projects.');
|
|
||||||
}
|
|
||||||
nextStepsLines.push(`View your memories: ${pc.underline(`http://localhost:${settingsConfig.workerPort}`)}`);
|
|
||||||
nextStepsLines.push(`Search past work: use ${pc.bold('/mem-search')} in Claude Code`);
|
|
||||||
|
|
||||||
p.note(nextStepsLines.join('\n'), 'Next Steps');
|
|
||||||
|
|
||||||
p.outro(pc.green('claude-mem installed successfully!'));
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import { findBinary, compareVersions, installBun, installUv } from '../utils/dependencies.js';
|
|
||||||
import { detectOS } from '../utils/system.js';
|
|
||||||
|
|
||||||
const BUN_EXTRA_PATHS = ['~/.bun/bin/bun', '/usr/local/bin/bun', '/opt/homebrew/bin/bun'];
|
|
||||||
const UV_EXTRA_PATHS = ['~/.local/bin/uv', '~/.cargo/bin/uv'];
|
|
||||||
|
|
||||||
interface DependencyStatus {
|
|
||||||
nodeOk: boolean;
|
|
||||||
gitOk: boolean;
|
|
||||||
bunOk: boolean;
|
|
||||||
uvOk: boolean;
|
|
||||||
bunPath: string | null;
|
|
||||||
uvPath: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runDependencyChecks(): Promise<DependencyStatus> {
|
|
||||||
const status: DependencyStatus = {
|
|
||||||
nodeOk: false,
|
|
||||||
gitOk: false,
|
|
||||||
bunOk: false,
|
|
||||||
uvOk: false,
|
|
||||||
bunPath: null,
|
|
||||||
uvPath: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await p.tasks([
|
|
||||||
{
|
|
||||||
title: 'Checking Node.js',
|
|
||||||
task: async () => {
|
|
||||||
const version = process.version.slice(1); // remove 'v'
|
|
||||||
if (compareVersions(version, '18.0.0')) {
|
|
||||||
status.nodeOk = true;
|
|
||||||
return `Node.js ${process.version} ${pc.green('✓')}`;
|
|
||||||
}
|
|
||||||
return `Node.js ${process.version} — requires >= 18.0.0 ${pc.red('✗')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Checking git',
|
|
||||||
task: async () => {
|
|
||||||
const info = findBinary('git');
|
|
||||||
if (info.found) {
|
|
||||||
status.gitOk = true;
|
|
||||||
return `git ${info.version ?? ''} ${pc.green('✓')}`;
|
|
||||||
}
|
|
||||||
return `git not found ${pc.red('✗')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Checking Bun',
|
|
||||||
task: async () => {
|
|
||||||
const info = findBinary('bun', BUN_EXTRA_PATHS);
|
|
||||||
if (info.found && info.version && compareVersions(info.version, '1.1.14')) {
|
|
||||||
status.bunOk = true;
|
|
||||||
status.bunPath = info.path;
|
|
||||||
return `Bun ${info.version} ${pc.green('✓')}`;
|
|
||||||
}
|
|
||||||
if (info.found && info.version) {
|
|
||||||
return `Bun ${info.version} — requires >= 1.1.14 ${pc.yellow('⚠')}`;
|
|
||||||
}
|
|
||||||
return `Bun not found ${pc.yellow('⚠')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Checking uv',
|
|
||||||
task: async () => {
|
|
||||||
const info = findBinary('uv', UV_EXTRA_PATHS);
|
|
||||||
if (info.found) {
|
|
||||||
status.uvOk = true;
|
|
||||||
status.uvPath = info.path;
|
|
||||||
return `uv ${info.version ?? ''} ${pc.green('✓')}`;
|
|
||||||
}
|
|
||||||
return `uv not found ${pc.yellow('⚠')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Handle missing dependencies
|
|
||||||
if (!status.gitOk) {
|
|
||||||
const os = detectOS();
|
|
||||||
p.log.error('git is required but not found.');
|
|
||||||
if (os === 'macos') {
|
|
||||||
p.log.info('Install with: xcode-select --install');
|
|
||||||
} else if (os === 'linux') {
|
|
||||||
p.log.info('Install with: sudo apt install git (or your distro equivalent)');
|
|
||||||
} else {
|
|
||||||
p.log.info('Download from: https://git-scm.com/downloads');
|
|
||||||
}
|
|
||||||
p.cancel('Please install git and try again.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status.nodeOk) {
|
|
||||||
p.log.error(`Node.js >= 18.0.0 is required. Current: ${process.version}`);
|
|
||||||
p.cancel('Please upgrade Node.js and try again.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status.bunOk) {
|
|
||||||
const shouldInstall = await p.confirm({
|
|
||||||
message: 'Bun is required but not found. Install it now?',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(shouldInstall)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldInstall) {
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Installing Bun...');
|
|
||||||
try {
|
|
||||||
installBun();
|
|
||||||
const recheck = findBinary('bun', BUN_EXTRA_PATHS);
|
|
||||||
if (recheck.found) {
|
|
||||||
status.bunOk = true;
|
|
||||||
status.bunPath = recheck.path;
|
|
||||||
s.stop(`Bun installed ${pc.green('✓')}`);
|
|
||||||
} else {
|
|
||||||
s.stop(`Bun installed but not found in PATH. You may need to restart your shell.`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
s.stop(`Bun installation failed. Install manually: curl -fsSL https://bun.sh/install | bash`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.log.warn('Bun is required for claude-mem. Install manually: curl -fsSL https://bun.sh/install | bash');
|
|
||||||
p.cancel('Cannot continue without Bun.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status.uvOk) {
|
|
||||||
const shouldInstall = await p.confirm({
|
|
||||||
message: 'uv (Python package manager) is recommended for Chroma. Install it now?',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(shouldInstall)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldInstall) {
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Installing uv...');
|
|
||||||
try {
|
|
||||||
installUv();
|
|
||||||
const recheck = findBinary('uv', UV_EXTRA_PATHS);
|
|
||||||
if (recheck.found) {
|
|
||||||
status.uvOk = true;
|
|
||||||
status.uvPath = recheck.path;
|
|
||||||
s.stop(`uv installed ${pc.green('✓')}`);
|
|
||||||
} else {
|
|
||||||
s.stop('uv installed but not found in PATH. You may need to restart your shell.');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
s.stop('uv installation failed. Install manually: curl -fsSL https://astral.sh/uv/install.sh | sh');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.log.warn('Skipping uv — Chroma vector search will not be available.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
|
|
||||||
export type IDE = 'claude-code' | 'cursor';
|
|
||||||
|
|
||||||
export async function runIdeSelection(): Promise<IDE[]> {
|
|
||||||
const result = await p.multiselect({
|
|
||||||
message: 'Which IDEs do you use?',
|
|
||||||
options: [
|
|
||||||
{ value: 'claude-code' as const, label: 'Claude Code', hint: 'recommended' },
|
|
||||||
{ value: 'cursor' as const, label: 'Cursor' },
|
|
||||||
// Windsurf coming soon - not yet selectable
|
|
||||||
],
|
|
||||||
initialValues: ['claude-code'],
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(result)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedIDEs = result as IDE[];
|
|
||||||
|
|
||||||
if (selectedIDEs.includes('claude-code')) {
|
|
||||||
p.log.info('Claude Code: Plugin will be registered via marketplace.');
|
|
||||||
}
|
|
||||||
if (selectedIDEs.includes('cursor')) {
|
|
||||||
p.log.info('Cursor: Hooks will be configured for your projects.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectedIDEs;
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { homedir, tmpdir } from 'os';
|
|
||||||
import type { IDE } from './ide-selection.js';
|
|
||||||
|
|
||||||
const MARKETPLACE_DIR = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
|
||||||
const PLUGINS_DIR = join(homedir(), '.claude', 'plugins');
|
|
||||||
const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
||||||
|
|
||||||
function ensureDir(directoryPath: string): void {
|
|
||||||
if (!existsSync(directoryPath)) {
|
|
||||||
mkdirSync(directoryPath, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJsonFile(filepath: string): any {
|
|
||||||
if (!existsSync(filepath)) return {};
|
|
||||||
return JSON.parse(readFileSync(filepath, 'utf-8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJsonFile(filepath: string, data: any): void {
|
|
||||||
ensureDir(join(filepath, '..'));
|
|
||||||
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerMarketplace(): void {
|
|
||||||
const knownMarketplacesPath = join(PLUGINS_DIR, 'known_marketplaces.json');
|
|
||||||
const knownMarketplaces = readJsonFile(knownMarketplacesPath);
|
|
||||||
|
|
||||||
knownMarketplaces['thedotmack'] = {
|
|
||||||
source: {
|
|
||||||
source: 'github',
|
|
||||||
repo: 'thedotmack/claude-mem',
|
|
||||||
},
|
|
||||||
installLocation: MARKETPLACE_DIR,
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
autoUpdate: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
ensureDir(PLUGINS_DIR);
|
|
||||||
writeJsonFile(knownMarketplacesPath, knownMarketplaces);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerPlugin(version: string): void {
|
|
||||||
const installedPluginsPath = join(PLUGINS_DIR, 'installed_plugins.json');
|
|
||||||
const installedPlugins = readJsonFile(installedPluginsPath);
|
|
||||||
|
|
||||||
if (!installedPlugins.version) installedPlugins.version = 2;
|
|
||||||
if (!installedPlugins.plugins) installedPlugins.plugins = {};
|
|
||||||
|
|
||||||
const pluginCachePath = join(PLUGINS_DIR, 'cache', 'thedotmack', 'claude-mem', version);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
installedPlugins.plugins['claude-mem@thedotmack'] = [
|
|
||||||
{
|
|
||||||
scope: 'user',
|
|
||||||
installPath: pluginCachePath,
|
|
||||||
version,
|
|
||||||
installedAt: now,
|
|
||||||
lastUpdated: now,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
writeJsonFile(installedPluginsPath, installedPlugins);
|
|
||||||
|
|
||||||
// Copy built plugin to cache directory
|
|
||||||
ensureDir(pluginCachePath);
|
|
||||||
const pluginSourceDir = join(MARKETPLACE_DIR, 'plugin');
|
|
||||||
if (existsSync(pluginSourceDir)) {
|
|
||||||
cpSync(pluginSourceDir, pluginCachePath, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enablePluginInClaudeSettings(): void {
|
|
||||||
const settings = readJsonFile(CLAUDE_SETTINGS_PATH);
|
|
||||||
|
|
||||||
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
|
||||||
settings.enabledPlugins['claude-mem@thedotmack'] = true;
|
|
||||||
|
|
||||||
writeJsonFile(CLAUDE_SETTINGS_PATH, settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPluginVersion(): string {
|
|
||||||
const pluginJsonPath = join(MARKETPLACE_DIR, 'plugin', '.claude-plugin', 'plugin.json');
|
|
||||||
if (existsSync(pluginJsonPath)) {
|
|
||||||
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
|
|
||||||
return pluginJson.version ?? '1.0.0';
|
|
||||||
}
|
|
||||||
return '1.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runInstallation(selectedIDEs: IDE[]): Promise<void> {
|
|
||||||
const tempDir = join(tmpdir(), `claude-mem-install-${Date.now()}`);
|
|
||||||
|
|
||||||
await p.tasks([
|
|
||||||
{
|
|
||||||
title: 'Cloning claude-mem repository',
|
|
||||||
task: async (message) => {
|
|
||||||
message('Downloading latest release...');
|
|
||||||
execSync(
|
|
||||||
`git clone --depth 1 https://github.com/thedotmack/claude-mem.git "${tempDir}"`,
|
|
||||||
{ stdio: 'pipe' },
|
|
||||||
);
|
|
||||||
return `Repository cloned ${pc.green('OK')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Installing dependencies',
|
|
||||||
task: async (message) => {
|
|
||||||
message('Running npm install...');
|
|
||||||
execSync('npm install', { cwd: tempDir, stdio: 'pipe' });
|
|
||||||
return `Dependencies installed ${pc.green('OK')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Building plugin',
|
|
||||||
task: async (message) => {
|
|
||||||
message('Compiling TypeScript and bundling...');
|
|
||||||
execSync('npm run build', { cwd: tempDir, stdio: 'pipe' });
|
|
||||||
return `Plugin built ${pc.green('OK')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Registering plugin',
|
|
||||||
task: async (message) => {
|
|
||||||
message('Copying files to marketplace directory...');
|
|
||||||
ensureDir(MARKETPLACE_DIR);
|
|
||||||
|
|
||||||
// Sync from cloned repo to marketplace dir, excluding .git and lock files
|
|
||||||
execSync(
|
|
||||||
`rsync -a --delete --exclude=.git --exclude=package-lock.json --exclude=bun.lock "${tempDir}/" "${MARKETPLACE_DIR}/"`,
|
|
||||||
{ stdio: 'pipe' },
|
|
||||||
);
|
|
||||||
|
|
||||||
message('Registering marketplace...');
|
|
||||||
registerMarketplace();
|
|
||||||
|
|
||||||
message('Installing marketplace dependencies...');
|
|
||||||
execSync('npm install', { cwd: MARKETPLACE_DIR, stdio: 'pipe' });
|
|
||||||
|
|
||||||
message('Registering plugin in Claude Code...');
|
|
||||||
const version = getPluginVersion();
|
|
||||||
registerPlugin(version);
|
|
||||||
|
|
||||||
message('Enabling plugin...');
|
|
||||||
enablePluginInClaudeSettings();
|
|
||||||
|
|
||||||
return `Plugin registered (v${getPluginVersion()}) ${pc.green('OK')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Cleanup temp directory (non-critical if it fails)
|
|
||||||
try {
|
|
||||||
execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
|
|
||||||
} catch {
|
|
||||||
// Temp dir will be cleaned by OS eventually
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedIDEs.includes('cursor')) {
|
|
||||||
p.log.info('Cursor hook configuration will be available after first launch.');
|
|
||||||
p.log.info('Run: claude-mem cursor-setup (coming soon)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
|
|
||||||
export type ProviderType = 'claude' | 'gemini' | 'openrouter';
|
|
||||||
export type ClaudeAuthMethod = 'cli' | 'api';
|
|
||||||
|
|
||||||
export interface ProviderConfig {
|
|
||||||
provider: ProviderType;
|
|
||||||
claudeAuthMethod?: ClaudeAuthMethod;
|
|
||||||
apiKey?: string;
|
|
||||||
model?: string;
|
|
||||||
rateLimitingEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runProviderConfiguration(): Promise<ProviderConfig> {
|
|
||||||
const provider = await p.select({
|
|
||||||
message: 'Which AI provider should claude-mem use for memory compression?',
|
|
||||||
options: [
|
|
||||||
{ value: 'claude' as const, label: 'Claude', hint: 'uses your Claude subscription' },
|
|
||||||
{ value: 'gemini' as const, label: 'Gemini', hint: 'free tier available' },
|
|
||||||
{ value: 'openrouter' as const, label: 'OpenRouter', hint: 'free models available' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(provider)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: ProviderConfig = { provider };
|
|
||||||
|
|
||||||
if (provider === 'claude') {
|
|
||||||
const authMethod = await p.select({
|
|
||||||
message: 'How should Claude authenticate?',
|
|
||||||
options: [
|
|
||||||
{ value: 'cli' as const, label: 'CLI (Max Plan subscription)', hint: 'no API key needed' },
|
|
||||||
{ value: 'api' as const, label: 'API Key', hint: 'uses Anthropic API credits' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(authMethod)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.claudeAuthMethod = authMethod;
|
|
||||||
|
|
||||||
if (authMethod === 'api') {
|
|
||||||
const apiKey = await p.password({
|
|
||||||
message: 'Enter your Anthropic API key:',
|
|
||||||
validate: (value) => {
|
|
||||||
if (!value || value.trim().length === 0) return 'API key is required';
|
|
||||||
if (!value.startsWith('sk-ant-')) return 'Anthropic API keys start with sk-ant-';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(apiKey)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.apiKey = apiKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'gemini') {
|
|
||||||
const apiKey = await p.password({
|
|
||||||
message: 'Enter your Gemini API key:',
|
|
||||||
validate: (value) => {
|
|
||||||
if (!value || value.trim().length === 0) return 'API key is required';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(apiKey)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.apiKey = apiKey;
|
|
||||||
|
|
||||||
const model = await p.select({
|
|
||||||
message: 'Which Gemini model?',
|
|
||||||
options: [
|
|
||||||
{ value: 'gemini-2.5-flash-lite' as const, label: 'Gemini 2.5 Flash Lite', hint: 'fastest, highest free RPM' },
|
|
||||||
{ value: 'gemini-2.5-flash' as const, label: 'Gemini 2.5 Flash', hint: 'balanced' },
|
|
||||||
{ value: 'gemini-3-flash-preview' as const, label: 'Gemini 3 Flash Preview', hint: 'latest' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(model)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.model = model;
|
|
||||||
|
|
||||||
const rateLimiting = await p.confirm({
|
|
||||||
message: 'Enable rate limiting? (recommended for free tier)',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(rateLimiting)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.rateLimitingEnabled = rateLimiting;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
|
||||||
const apiKey = await p.password({
|
|
||||||
message: 'Enter your OpenRouter API key:',
|
|
||||||
validate: (value) => {
|
|
||||||
if (!value || value.trim().length === 0) return 'API key is required';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(apiKey)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.apiKey = apiKey;
|
|
||||||
|
|
||||||
const model = await p.text({
|
|
||||||
message: 'Which OpenRouter model?',
|
|
||||||
defaultValue: 'xiaomi/mimo-v2-flash:free',
|
|
||||||
placeholder: 'xiaomi/mimo-v2-flash:free',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(model)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.model = model;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
|
|
||||||
export interface SettingsConfig {
|
|
||||||
workerPort: string;
|
|
||||||
dataDir: string;
|
|
||||||
contextObservations: string;
|
|
||||||
logLevel: string;
|
|
||||||
pythonVersion: string;
|
|
||||||
chromaEnabled: boolean;
|
|
||||||
chromaMode?: 'local' | 'remote';
|
|
||||||
chromaHost?: string;
|
|
||||||
chromaPort?: string;
|
|
||||||
chromaSsl?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSettingsConfiguration(): Promise<SettingsConfig> {
|
|
||||||
const useDefaults = await p.confirm({
|
|
||||||
message: 'Use default settings? (recommended for most users)',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(useDefaults)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useDefaults) {
|
|
||||||
return {
|
|
||||||
workerPort: '37777',
|
|
||||||
dataDir: '~/.claude-mem',
|
|
||||||
contextObservations: '50',
|
|
||||||
logLevel: 'INFO',
|
|
||||||
pythonVersion: '3.13',
|
|
||||||
chromaEnabled: true,
|
|
||||||
chromaMode: 'local',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom settings
|
|
||||||
const workerPort = await p.text({
|
|
||||||
message: 'Worker service port:',
|
|
||||||
defaultValue: '37777',
|
|
||||||
placeholder: '37777',
|
|
||||||
validate: (value = '') => {
|
|
||||||
const port = parseInt(value, 10);
|
|
||||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
||||||
return 'Port must be between 1024 and 65535';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (p.isCancel(workerPort)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const dataDir = await p.text({
|
|
||||||
message: 'Data directory:',
|
|
||||||
defaultValue: '~/.claude-mem',
|
|
||||||
placeholder: '~/.claude-mem',
|
|
||||||
});
|
|
||||||
if (p.isCancel(dataDir)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const contextObservations = await p.text({
|
|
||||||
message: 'Number of context observations per session:',
|
|
||||||
defaultValue: '50',
|
|
||||||
placeholder: '50',
|
|
||||||
validate: (value = '') => {
|
|
||||||
const num = parseInt(value, 10);
|
|
||||||
if (isNaN(num) || num < 1 || num > 200) {
|
|
||||||
return 'Must be between 1 and 200';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (p.isCancel(contextObservations)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const logLevel = await p.select({
|
|
||||||
message: 'Log level:',
|
|
||||||
options: [
|
|
||||||
{ value: 'DEBUG', label: 'DEBUG', hint: 'verbose' },
|
|
||||||
{ value: 'INFO', label: 'INFO', hint: 'default' },
|
|
||||||
{ value: 'WARN', label: 'WARN' },
|
|
||||||
{ value: 'ERROR', label: 'ERROR', hint: 'errors only' },
|
|
||||||
],
|
|
||||||
initialValue: 'INFO',
|
|
||||||
});
|
|
||||||
if (p.isCancel(logLevel)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const pythonVersion = await p.text({
|
|
||||||
message: 'Python version (for Chroma):',
|
|
||||||
defaultValue: '3.13',
|
|
||||||
placeholder: '3.13',
|
|
||||||
});
|
|
||||||
if (p.isCancel(pythonVersion)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const chromaEnabled = await p.confirm({
|
|
||||||
message: 'Enable Chroma vector search?',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
if (p.isCancel(chromaEnabled)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
let chromaMode: 'local' | 'remote' | undefined;
|
|
||||||
let chromaHost: string | undefined;
|
|
||||||
let chromaPort: string | undefined;
|
|
||||||
let chromaSsl: boolean | undefined;
|
|
||||||
|
|
||||||
if (chromaEnabled) {
|
|
||||||
const mode = await p.select({
|
|
||||||
message: 'Chroma mode:',
|
|
||||||
options: [
|
|
||||||
{ value: 'local' as const, label: 'Local', hint: 'starts local Chroma server' },
|
|
||||||
{ value: 'remote' as const, label: 'Remote', hint: 'connect to existing server' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (p.isCancel(mode)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
chromaMode = mode;
|
|
||||||
|
|
||||||
if (mode === 'remote') {
|
|
||||||
const host = await p.text({
|
|
||||||
message: 'Chroma host:',
|
|
||||||
defaultValue: '127.0.0.1',
|
|
||||||
placeholder: '127.0.0.1',
|
|
||||||
});
|
|
||||||
if (p.isCancel(host)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
chromaHost = host;
|
|
||||||
|
|
||||||
const port = await p.text({
|
|
||||||
message: 'Chroma port:',
|
|
||||||
defaultValue: '8000',
|
|
||||||
placeholder: '8000',
|
|
||||||
validate: (value = '') => {
|
|
||||||
const portNum = parseInt(value, 10);
|
|
||||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return 'Port must be between 1 and 65535';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (p.isCancel(port)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
chromaPort = port;
|
|
||||||
|
|
||||||
const ssl = await p.confirm({
|
|
||||||
message: 'Use SSL for Chroma connection?',
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
if (p.isCancel(ssl)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
chromaSsl = ssl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: SettingsConfig = {
|
|
||||||
workerPort,
|
|
||||||
dataDir,
|
|
||||||
contextObservations,
|
|
||||||
logLevel,
|
|
||||||
pythonVersion,
|
|
||||||
chromaEnabled,
|
|
||||||
chromaMode,
|
|
||||||
chromaHost,
|
|
||||||
chromaPort,
|
|
||||||
chromaSsl,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show summary
|
|
||||||
const summaryLines = [
|
|
||||||
`Worker port: ${pc.cyan(workerPort)}`,
|
|
||||||
`Data directory: ${pc.cyan(dataDir)}`,
|
|
||||||
`Context observations: ${pc.cyan(contextObservations)}`,
|
|
||||||
`Log level: ${pc.cyan(logLevel)}`,
|
|
||||||
`Python version: ${pc.cyan(pythonVersion)}`,
|
|
||||||
`Chroma: ${chromaEnabled ? pc.green('enabled') : pc.dim('disabled')}`,
|
|
||||||
];
|
|
||||||
if (chromaEnabled && chromaMode) {
|
|
||||||
summaryLines.push(`Chroma mode: ${pc.cyan(chromaMode)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
p.note(summaryLines.join('\n'), 'Settings Summary');
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { expandHome } from '../utils/system.js';
|
|
||||||
|
|
||||||
export type InstallMode = 'fresh' | 'upgrade' | 'configure';
|
|
||||||
|
|
||||||
export async function runWelcome(): Promise<InstallMode> {
|
|
||||||
p.intro(pc.bgCyan(pc.black(' claude-mem installer ')));
|
|
||||||
|
|
||||||
p.log.info(`Version: 1.0.0`);
|
|
||||||
p.log.info(`Platform: ${process.platform} (${process.arch})`);
|
|
||||||
|
|
||||||
const settingsExist = existsSync(expandHome('~/.claude-mem/settings.json'));
|
|
||||||
const pluginExist = existsSync(expandHome('~/.claude/plugins/marketplaces/thedotmack/'));
|
|
||||||
|
|
||||||
const alreadyInstalled = settingsExist && pluginExist;
|
|
||||||
|
|
||||||
if (alreadyInstalled) {
|
|
||||||
p.log.warn('Existing claude-mem installation detected.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const installMode = await p.select({
|
|
||||||
message: 'What would you like to do?',
|
|
||||||
options: alreadyInstalled
|
|
||||||
? [
|
|
||||||
{ value: 'upgrade' as const, label: 'Upgrade', hint: 'update to latest version' },
|
|
||||||
{ value: 'configure' as const, label: 'Configure', hint: 'change settings only' },
|
|
||||||
{ value: 'fresh' as const, label: 'Fresh Install', hint: 'reinstall from scratch' },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ value: 'fresh' as const, label: 'Fresh Install', hint: 'recommended' },
|
|
||||||
{ value: 'configure' as const, label: 'Configure Only', hint: 'set up settings without installing' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(installMode)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return installMode;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import { expandHome } from '../utils/system.js';
|
|
||||||
import { findBinary } from '../utils/dependencies.js';
|
|
||||||
|
|
||||||
const MARKETPLACE_DIR = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
|
||||||
|
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 1000;
|
|
||||||
const HEALTH_CHECK_MAX_ATTEMPTS = 30;
|
|
||||||
|
|
||||||
async function pollHealthEndpoint(port: string, maxAttempts: number = HEALTH_CHECK_MAX_ATTEMPTS): Promise<boolean> {
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
|
||||||
if (response.ok) return true;
|
|
||||||
} catch {
|
|
||||||
// Expected during startup — worker not listening yet
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runWorkerStartup(workerPort: string, dataDir: string): Promise<void> {
|
|
||||||
const bunInfo = findBinary('bun', ['~/.bun/bin/bun', '/usr/local/bin/bun', '/opt/homebrew/bin/bun']);
|
|
||||||
|
|
||||||
if (!bunInfo.found || !bunInfo.path) {
|
|
||||||
p.log.error('Bun is required to start the worker but was not found.');
|
|
||||||
p.log.info('Install Bun: curl -fsSL https://bun.sh/install | bash');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workerScript = join(MARKETPLACE_DIR, 'plugin', 'scripts', 'worker-service.cjs');
|
|
||||||
const expandedDataDir = expandHome(dataDir);
|
|
||||||
const logPath = join(expandedDataDir, 'logs');
|
|
||||||
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Starting worker service...');
|
|
||||||
|
|
||||||
// Start worker as a detached background process
|
|
||||||
const child = spawn(bunInfo.path, [workerScript], {
|
|
||||||
cwd: MARKETPLACE_DIR,
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
CLAUDE_MEM_WORKER_PORT: workerPort,
|
|
||||||
CLAUDE_MEM_DATA_DIR: expandedDataDir,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
child.unref();
|
|
||||||
|
|
||||||
// Poll the health endpoint until the worker is responsive
|
|
||||||
const workerIsHealthy = await pollHealthEndpoint(workerPort);
|
|
||||||
|
|
||||||
if (workerIsHealthy) {
|
|
||||||
s.stop(`Worker running on port ${pc.cyan(workerPort)} ${pc.green('OK')}`);
|
|
||||||
} else {
|
|
||||||
s.stop(`Worker may still be starting. Check logs at: ${logPath}`);
|
|
||||||
p.log.warn('Health check timed out. The worker might need more time to initialize.');
|
|
||||||
p.log.info(`Check status: curl http://127.0.0.1:${workerPort}/api/health`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { existsSync } from 'fs';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { commandExists, runCommand, expandHome, detectOS } from './system.js';
|
|
||||||
|
|
||||||
export interface BinaryInfo {
|
|
||||||
found: boolean;
|
|
||||||
path: string | null;
|
|
||||||
version: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findBinary(name: string, extraPaths: string[] = []): BinaryInfo {
|
|
||||||
// Check PATH first
|
|
||||||
if (commandExists(name)) {
|
|
||||||
const result = runCommand('which', [name]);
|
|
||||||
const versionResult = runCommand(name, ['--version']);
|
|
||||||
return {
|
|
||||||
found: true,
|
|
||||||
path: result.stdout,
|
|
||||||
version: parseVersion(versionResult.stdout) || parseVersion(versionResult.stderr),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check extra known locations
|
|
||||||
for (const extraPath of extraPaths) {
|
|
||||||
const fullPath = expandHome(extraPath);
|
|
||||||
if (existsSync(fullPath)) {
|
|
||||||
const versionResult = runCommand(fullPath, ['--version']);
|
|
||||||
return {
|
|
||||||
found: true,
|
|
||||||
path: fullPath,
|
|
||||||
version: parseVersion(versionResult.stdout) || parseVersion(versionResult.stderr),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { found: false, path: null, version: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVersion(output: string): string | null {
|
|
||||||
if (!output) return null;
|
|
||||||
const match = output.match(/(\d+\.\d+(\.\d+)?)/);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareVersions(current: string, minimum: string): boolean {
|
|
||||||
const currentParts = current.split('.').map(Number);
|
|
||||||
const minimumParts = minimum.split('.').map(Number);
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.max(currentParts.length, minimumParts.length); i++) {
|
|
||||||
const a = currentParts[i] || 0;
|
|
||||||
const b = minimumParts[i] || 0;
|
|
||||||
if (a > b) return true;
|
|
||||||
if (a < b) return false;
|
|
||||||
}
|
|
||||||
return true; // equal
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installBun(): void {
|
|
||||||
const os = detectOS();
|
|
||||||
if (os === 'windows') {
|
|
||||||
execSync('powershell -c "irm bun.sh/install.ps1 | iex"', { stdio: 'inherit' });
|
|
||||||
} else {
|
|
||||||
execSync('curl -fsSL https://bun.sh/install | bash', { stdio: 'inherit' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installUv(): void {
|
|
||||||
const os = detectOS();
|
|
||||||
if (os === 'windows') {
|
|
||||||
execSync('powershell -c "irm https://astral.sh/uv/install.ps1 | iex"', { stdio: 'inherit' });
|
|
||||||
} else {
|
|
||||||
execSync('curl -fsSL https://astral.sh/uv/install.sh | sh', { stdio: 'inherit' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import type { ProviderConfig } from '../steps/provider.js';
|
|
||||||
import type { SettingsConfig } from '../steps/settings.js';
|
|
||||||
|
|
||||||
export function expandDataDir(dataDir: string): string {
|
|
||||||
if (dataDir.startsWith('~')) {
|
|
||||||
return join(homedir(), dataDir.slice(1));
|
|
||||||
}
|
|
||||||
return dataDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSettingsObject(
|
|
||||||
providerConfig: ProviderConfig,
|
|
||||||
settingsConfig: SettingsConfig,
|
|
||||||
): Record<string, string> {
|
|
||||||
const settings: Record<string, string> = {
|
|
||||||
CLAUDE_MEM_WORKER_PORT: settingsConfig.workerPort,
|
|
||||||
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
|
||||||
CLAUDE_MEM_DATA_DIR: expandDataDir(settingsConfig.dataDir),
|
|
||||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: settingsConfig.contextObservations,
|
|
||||||
CLAUDE_MEM_LOG_LEVEL: settingsConfig.logLevel,
|
|
||||||
CLAUDE_MEM_PYTHON_VERSION: settingsConfig.pythonVersion,
|
|
||||||
CLAUDE_MEM_PROVIDER: providerConfig.provider,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Provider-specific settings
|
|
||||||
if (providerConfig.provider === 'claude') {
|
|
||||||
settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD = providerConfig.claudeAuthMethod ?? 'cli';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerConfig.provider === 'gemini') {
|
|
||||||
if (providerConfig.apiKey) settings.CLAUDE_MEM_GEMINI_API_KEY = providerConfig.apiKey;
|
|
||||||
if (providerConfig.model) settings.CLAUDE_MEM_GEMINI_MODEL = providerConfig.model;
|
|
||||||
settings.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED = providerConfig.rateLimitingEnabled !== false ? 'true' : 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerConfig.provider === 'openrouter') {
|
|
||||||
if (providerConfig.apiKey) settings.CLAUDE_MEM_OPENROUTER_API_KEY = providerConfig.apiKey;
|
|
||||||
if (providerConfig.model) settings.CLAUDE_MEM_OPENROUTER_MODEL = providerConfig.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chroma settings
|
|
||||||
if (settingsConfig.chromaEnabled) {
|
|
||||||
settings.CLAUDE_MEM_CHROMA_MODE = settingsConfig.chromaMode ?? 'local';
|
|
||||||
if (settingsConfig.chromaMode === 'remote') {
|
|
||||||
if (settingsConfig.chromaHost) settings.CLAUDE_MEM_CHROMA_HOST = settingsConfig.chromaHost;
|
|
||||||
if (settingsConfig.chromaPort) settings.CLAUDE_MEM_CHROMA_PORT = settingsConfig.chromaPort;
|
|
||||||
if (settingsConfig.chromaSsl !== undefined) settings.CLAUDE_MEM_CHROMA_SSL = String(settingsConfig.chromaSsl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeSettings(
|
|
||||||
providerConfig: ProviderConfig,
|
|
||||||
settingsConfig: SettingsConfig,
|
|
||||||
): void {
|
|
||||||
const dataDir = expandDataDir(settingsConfig.dataDir);
|
|
||||||
const settingsPath = join(dataDir, 'settings.json');
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
if (!existsSync(dataDir)) {
|
|
||||||
mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge with existing settings if upgrading
|
|
||||||
let existingSettings: Record<string, string> = {};
|
|
||||||
if (existsSync(settingsPath)) {
|
|
||||||
const raw = readFileSync(settingsPath, 'utf-8');
|
|
||||||
existingSettings = JSON.parse(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSettings = buildSettingsObject(providerConfig, settingsConfig);
|
|
||||||
|
|
||||||
// Merge: new settings override existing ones
|
|
||||||
const merged = { ...existingSettings, ...newSettings };
|
|
||||||
|
|
||||||
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { execSync } from 'child_process';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
export type OSType = 'macos' | 'linux' | 'windows';
|
|
||||||
|
|
||||||
export function detectOS(): OSType {
|
|
||||||
switch (process.platform) {
|
|
||||||
case 'darwin': return 'macos';
|
|
||||||
case 'win32': return 'windows';
|
|
||||||
default: return 'linux';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commandExists(command: string): boolean {
|
|
||||||
try {
|
|
||||||
execSync(`which ${command}`, { stdio: 'pipe' });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandResult {
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
exitCode: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runCommand(command: string, args: string[] = []): CommandResult {
|
|
||||||
try {
|
|
||||||
const fullCommand = [command, ...args].join(' ');
|
|
||||||
const stdout = execSync(fullCommand, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
||||||
return { stdout: stdout.trim(), stderr: '', exitCode: 0 };
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
stdout: error.stdout?.toString().trim() ?? '',
|
|
||||||
stderr: error.stderr?.toString().trim() ?? '',
|
|
||||||
exitCode: error.status ?? 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandHome(filepath: string): string {
|
|
||||||
if (filepath.startsWith('~')) {
|
|
||||||
return join(homedir(), filepath.slice(1));
|
|
||||||
}
|
|
||||||
return filepath;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "ESNext",
|
|
||||||
"target": "ES2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"strict": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"declaration": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"forceConsistentCasingInFileNames": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
+17
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "10.6.3",
|
"version": "11.0.0",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
"url": "https://github.com/thedotmack/claude-mem/issues"
|
"url": "https://github.com/thedotmack/claude-mem/issues"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"claude-mem": "./dist/npx-cli/index.js"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -39,7 +42,17 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"plugin"
|
"plugin/.claude-plugin",
|
||||||
|
"plugin/CLAUDE.md",
|
||||||
|
"plugin/package.json",
|
||||||
|
"plugin/hooks",
|
||||||
|
"plugin/modes",
|
||||||
|
"plugin/scripts/*.js",
|
||||||
|
"plugin/scripts/*.cjs",
|
||||||
|
"plugin/scripts/CLAUDE.md",
|
||||||
|
"plugin/skills",
|
||||||
|
"plugin/ui",
|
||||||
|
"openclaw"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0",
|
"node": ">=18.0.0",
|
||||||
@@ -97,12 +110,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
||||||
|
"@clack/prompts": "^0.9.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "10.6.3",
|
"version": "11.0.0",
|
||||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Alex Newman"
|
"name": "Alex Newman"
|
||||||
|
|||||||
@@ -86,8 +86,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}},()=>process.exit(0));r.on('error',()=>process.exit(0));r.end(JSON.stringify({contentSessionId:s}));setTimeout(()=>process.exit(0),3000)}catch{process.exit(0)}})\"",
|
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}});r.on('error',()=>{});r.end(JSON.stringify({contentSessionId:s}));process.exit(0)}catch{process.exit(0)}})\"",
|
||||||
"timeout": 5
|
"timeout": 2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,8 @@
|
|||||||
"system_identity": "You are a Claude-Mem, a specialized observer tool for creating searchable memory FOR FUTURE SESSIONS.\n\nCRITICAL: Record what was LEARNED/BUILT/FIXED/DEPLOYED/CONFIGURED, not what you (the observer) are doing.\n\nYou do not have access to tools. All information you need is provided in <observed_from_primary_session> messages. Create observations from what you observe - no investigation needed.",
|
"system_identity": "You are a Claude-Mem, a specialized observer tool for creating searchable memory FOR FUTURE SESSIONS.\n\nCRITICAL: Record what was LEARNED/BUILT/FIXED/DEPLOYED/CONFIGURED, not what you (the observer) are doing.\n\nYou do not have access to tools. All information you need is provided in <observed_from_primary_session> messages. Create observations from what you observe - no investigation needed.",
|
||||||
"spatial_awareness": "SPATIAL AWARENESS: Tool executions include the working directory (tool_cwd) to help you understand:\n- Which repository/project is being worked on\n- Where files are located relative to the project root\n- How to match requested paths to actual execution paths",
|
"spatial_awareness": "SPATIAL AWARENESS: Tool executions include the working directory (tool_cwd) to help you understand:\n- Which repository/project is being worked on\n- Where files are located relative to the project root\n- How to match requested paths to actual execution paths",
|
||||||
"observer_role": "Your job is to monitor a different Claude Code session happening RIGHT NOW, with the goal of creating observations and progress summaries as the work is being done LIVE by the user. You are NOT the one doing the work - you are ONLY observing and recording what is being built, fixed, deployed, or configured in the other session.",
|
"observer_role": "Your job is to monitor a different Claude Code session happening RIGHT NOW, with the goal of creating observations and progress summaries as the work is being done LIVE by the user. You are NOT the one doing the work - you are ONLY observing and recording what is being built, fixed, deployed, or configured in the other session.",
|
||||||
"recording_focus": "WHAT TO RECORD\n--------------\nFocus on deliverables and capabilities:\n- What the system NOW DOES differently (new capabilities)\n- What shipped to users/production (features, fixes, configs, docs)\n- Changes in technical domains (auth, data, UI, infra, DevOps, docs)\n\nUse verbs like: implemented, fixed, deployed, configured, migrated, optimized, added, refactored\n\n✅ GOOD EXAMPLES (describes what was built):\n- \"Authentication now supports OAuth2 with PKCE flow\"\n- \"Deployment pipeline runs canary releases with auto-rollback\"\n- \"Database indexes optimized for common query patterns\"\n\n❌ BAD EXAMPLES (describes observation process - DO NOT DO THIS):\n- \"Analyzed authentication implementation and stored findings\"\n- \"Tracked deployment steps and logged outcomes\"\n- \"Monitored database performance and recorded metrics\"",
|
"recording_focus": "WHAT TO RECORD\n--------------\nFocus on durable technical signal:\n- What the system NOW DOES differently (new capabilities)\n- What shipped to users/production (features, fixes, configs, docs)\n- Changes in technical domains (auth, data, UI, infra, DevOps, docs)\n- Concrete debugging or investigative findings from logs, traces, queue state, database rows, and code-path inspection\n\nUse verbs like: implemented, fixed, deployed, configured, migrated, optimized, added, refactored, discovered, confirmed, traced\n\n✅ GOOD EXAMPLES (describes what was built or learned):\n- \"Authentication now supports OAuth2 with PKCE flow\"\n- \"Deployment pipeline runs canary releases with auto-rollback\"\n- \"Database indexes optimized for common query patterns\"\n- \"Observation queue for claude-mem session timed out waiting for an agent pool slot\"\n- \"Fallback processing abandoned pending messages after Gemini and OpenRouter returned 404\"\n\n❌ BAD EXAMPLES (describes observation process - DO NOT DO THIS):\n- \"Analyzed authentication implementation and stored findings\"\n- \"Tracked deployment steps and logged outcomes\"\n- \"Monitored database performance and recorded metrics\"",
|
||||||
"skip_guidance": "WHEN TO SKIP\n------------\nSkip routine operations:\n- Empty status checks\n- Package installations with no errors\n- Simple file listings\n- Repetitive operations you've already documented\n- If file related research comes back as empty or not found\n- **No output necessary if skipping.**",
|
"skip_guidance": "WHEN TO SKIP\n------------\nSkip routine operations:\n- Empty status checks\n- Package installations with no errors\n- Simple file listings with no follow-on finding\n- Repetitive operations you've already documented\n- File related research that comes back empty or not found\n\nIf skipping, return an empty response only. Do not explain the skip in prose.",
|
||||||
"type_guidance": "**type**: MUST be EXACTLY one of these 6 options (no other values allowed):\n - bugfix: something was broken, now fixed\n - feature: new capability or functionality added\n - refactor: code restructured, behavior unchanged\n - change: generic modification (docs, config, misc)\n - discovery: learning about existing system\n - decision: architectural/design choice with rationale",
|
"type_guidance": "**type**: MUST be EXACTLY one of these 6 options (no other values allowed):\n - bugfix: something was broken, now fixed\n - feature: new capability or functionality added\n - refactor: code restructured, behavior unchanged\n - change: generic modification (docs, config, misc)\n - discovery: learning about existing system\n - decision: architectural/design choice with rationale",
|
||||||
"concept_guidance": "**concepts**: 2-5 knowledge-type categories. MUST use ONLY these exact keywords:\n - how-it-works: understanding mechanisms\n - why-it-exists: purpose or rationale\n - what-changed: modifications made\n - problem-solution: issues and their fixes\n - gotcha: traps or edge cases\n - pattern: reusable approach\n - trade-off: pros/cons of a decision\n\n IMPORTANT: Do NOT include the observation type (change/discovery/decision) as a concept.\n Types and concepts are separate dimensions.",
|
"concept_guidance": "**concepts**: 2-5 knowledge-type categories. MUST use ONLY these exact keywords:\n - how-it-works: understanding mechanisms\n - why-it-exists: purpose or rationale\n - what-changed: modifications made\n - problem-solution: issues and their fixes\n - gotcha: traps or edge cases\n - pattern: reusable approach\n - trade-off: pros/cons of a decision\n\n IMPORTANT: Do NOT include the observation type (change/discovery/decision) as a concept.\n Types and concepts are separate dimensions.",
|
||||||
"field_guidance": "**facts**: Concise, self-contained statements\nEach fact is ONE piece of information\n No pronouns - each fact must stand alone\n Include specific details: filenames, functions, values\n\n**files**: All files touched (full paths from project root)",
|
"field_guidance": "**facts**: Concise, self-contained statements\nEach fact is ONE piece of information\n No pronouns - each fact must stand alone\n Include specific details: filenames, functions, values\n\n**files**: All files touched (full paths from project root)",
|
||||||
@@ -122,4 +122,4 @@
|
|||||||
"summary_format_instruction": "Respond in this XML format:",
|
"summary_format_instruction": "Respond in this XML format:",
|
||||||
"summary_footer": "IMPORTANT! DO NOT do any work right now other than generating this next PROGRESS SUMMARY - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the summary content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful summary content.\n\nThank you, this summary will be very useful for keeping track of our progress!"
|
"summary_footer": "IMPORTANT! DO NOT do any work right now other than generating this next PROGRESS SUMMARY - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the summary content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful summary content.\n\nThank you, this summary will be very useful for keeping track of our progress!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem-plugin",
|
"name": "claude-mem-plugin",
|
||||||
"version": "10.6.3",
|
"version": "11.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+265
-203
File diff suppressed because one or more lines are too long
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: claude-code-plugin-release
|
||||||
|
description: Automated semantic versioning and release workflow for Claude Code plugins. Handles version increments across package.json, marketplace.json, and plugin.json, build verification, git tagging, GitHub releases, and changelog generation.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Version Bump & Release Workflow
|
||||||
|
|
||||||
|
**IMPORTANT:** You must first plan and write detailed release notes before starting the version bump workflow.
|
||||||
|
|
||||||
|
**CRITICAL:** ALWAYS commit EVERYTHING (including build artifacts). At the end of this workflow, NOTHING should be left uncommitted or unpushed. Run `git status` at the end to verify.
|
||||||
|
|
||||||
|
## Preparation
|
||||||
|
|
||||||
|
1. **Analyze**: Determine if the change is a **PATCH** (bug fixes), **MINOR** (features), or **MAJOR** (breaking) update.
|
||||||
|
2. **Environment**: Identify the repository owner and name (e.g., from `git remote -v`).
|
||||||
|
3. **Paths**: Verify existence of `package.json`, `.claude-plugin/marketplace.json`, and `plugin/.claude-plugin/plugin.json`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Update**: Increment version strings in all configuration files.
|
||||||
|
2. **Verify**: Use `grep` to ensure all files match the new version.
|
||||||
|
3. **Build**: Run `npm run build` to generate fresh artifacts.
|
||||||
|
4. **Commit**: Stage all changes including artifacts: `git add -A && git commit -m "chore: bump version to X.Y.Z"`.
|
||||||
|
5. **Tag**: Create an annotated tag: `git tag -a vX.Y.Z -m "Version X.Y.Z"`.
|
||||||
|
6. **Push**: `git push origin main && git push origin vX.Y.Z`.
|
||||||
|
7. **Release**: `gh release create vX.Y.Z --title "vX.Y.Z" --notes "RELEASE_NOTES"`.
|
||||||
|
8. **Changelog**: Regenerate `CHANGELOG.md` using the GitHub API and the provided script:
|
||||||
|
```bash
|
||||||
|
gh api repos/{owner}/{repo}/releases --paginate | ./scripts/generate_changelog.js > CHANGELOG.md
|
||||||
|
```
|
||||||
|
9. **Sync**: Commit and push the updated `CHANGELOG.md`.
|
||||||
|
10. **Notify**: Run `npm run discord:notify vX.Y.Z` if applicable.
|
||||||
|
11. **Finalize**: Run `git status` to ensure a clean working tree.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] All config files have matching versions
|
||||||
|
- [ ] `npm run build` succeeded
|
||||||
|
- [ ] Git tag created and pushed
|
||||||
|
- [ ] GitHub release created with notes
|
||||||
|
- [ ] `CHANGELOG.md` updated and pushed
|
||||||
|
- [ ] `git status` shows clean tree
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes GitHub release JSON from stdin and outputs a formatted CHANGELOG.md
|
||||||
|
*/
|
||||||
|
function generate() {
|
||||||
|
try {
|
||||||
|
const input = fs.readFileSync(0, 'utf8');
|
||||||
|
if (!input || input.trim() === '') {
|
||||||
|
process.stderr.write('No input received on stdin
|
||||||
|
');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases = JSON.parse(input);
|
||||||
|
const lines = ['# Changelog', '', 'All notable changes to this project.', ''];
|
||||||
|
|
||||||
|
releases.slice(0, 50).forEach(r => {
|
||||||
|
const date = r.published_at.split('T')[0];
|
||||||
|
lines.push(`## [${r.tag_name}] - ${date}`);
|
||||||
|
lines.push('');
|
||||||
|
if (r.body) lines.push(r.body.trim());
|
||||||
|
lines.push('');
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdout.write(lines.join('
|
||||||
|
') + '
|
||||||
|
');
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`Error generating changelog: ${err.message}
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generate();
|
||||||
+11
-11
File diff suppressed because one or more lines are too long
+94
-1
@@ -187,6 +187,89 @@ async function buildHooks() {
|
|||||||
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
||||||
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
|
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
|
||||||
|
// Build NPX CLI (pure Node.js — no Bun dependency)
|
||||||
|
console.log(`\n🔧 Building NPX CLI...`);
|
||||||
|
const npxCliOutDir = 'dist/npx-cli';
|
||||||
|
if (!fs.existsSync(npxCliOutDir)) {
|
||||||
|
fs.mkdirSync(npxCliOutDir, { recursive: true });
|
||||||
|
}
|
||||||
|
await build({
|
||||||
|
entryPoints: ['src/npx-cli/index.ts'],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
format: 'esm',
|
||||||
|
outfile: `${npxCliOutDir}/index.js`,
|
||||||
|
banner: { js: '#!/usr/bin/env node' },
|
||||||
|
minify: true,
|
||||||
|
logLevel: 'error',
|
||||||
|
external: [
|
||||||
|
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||||
|
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||||
|
'buffer', 'querystring', 'readline', 'tty', 'assert',
|
||||||
|
],
|
||||||
|
define: {
|
||||||
|
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make NPX CLI executable
|
||||||
|
fs.chmodSync(`${npxCliOutDir}/index.js`, 0o755);
|
||||||
|
const npxCliStats = fs.statSync(`${npxCliOutDir}/index.js`);
|
||||||
|
console.log(`✓ npx-cli built (${(npxCliStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
|
||||||
|
// Build OpenClaw plugin (self-contained, only Node builtins external)
|
||||||
|
if (fs.existsSync('openclaw/src/index.ts')) {
|
||||||
|
console.log(`\n🔧 Building OpenClaw plugin...`);
|
||||||
|
const openclawOutDir = 'openclaw/dist';
|
||||||
|
if (!fs.existsSync(openclawOutDir)) {
|
||||||
|
fs.mkdirSync(openclawOutDir, { recursive: true });
|
||||||
|
}
|
||||||
|
await build({
|
||||||
|
entryPoints: ['openclaw/src/index.ts'],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
format: 'esm',
|
||||||
|
outfile: `${openclawOutDir}/index.js`,
|
||||||
|
minify: true,
|
||||||
|
logLevel: 'error',
|
||||||
|
external: [
|
||||||
|
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||||
|
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const openclawStats = fs.statSync(`${openclawOutDir}/index.js`);
|
||||||
|
console.log(`✓ openclaw plugin built (${(openclawStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build OpenCode plugin (self-contained, Node.js ESM — Bun-compatible)
|
||||||
|
if (fs.existsSync('src/integrations/opencode-plugin/index.ts')) {
|
||||||
|
console.log(`\n🔧 Building OpenCode plugin...`);
|
||||||
|
const opencodeOutDir = 'dist/opencode-plugin';
|
||||||
|
if (!fs.existsSync(opencodeOutDir)) {
|
||||||
|
fs.mkdirSync(opencodeOutDir, { recursive: true });
|
||||||
|
}
|
||||||
|
await build({
|
||||||
|
entryPoints: ['src/integrations/opencode-plugin/index.ts'],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
format: 'esm',
|
||||||
|
outfile: `${opencodeOutDir}/index.js`,
|
||||||
|
minify: true,
|
||||||
|
logLevel: 'error',
|
||||||
|
external: [
|
||||||
|
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||||
|
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const opencodeStats = fs.statSync(`${opencodeOutDir}/index.js`);
|
||||||
|
console.log(`✓ opencode plugin built (${(opencodeStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Verify critical distribution files exist (skills are source files, not build outputs)
|
// Verify critical distribution files exist (skills are source files, not build outputs)
|
||||||
console.log('\n📋 Verifying distribution files...');
|
console.log('\n📋 Verifying distribution files...');
|
||||||
const requiredDistributionFiles = [
|
const requiredDistributionFiles = [
|
||||||
@@ -202,11 +285,21 @@ async function buildHooks() {
|
|||||||
}
|
}
|
||||||
console.log('✓ All required distribution files present');
|
console.log('✓ All required distribution files present');
|
||||||
|
|
||||||
console.log('\n✅ Worker service, MCP server, and context generator built successfully!');
|
console.log('\n✅ All build targets compiled successfully!');
|
||||||
console.log(` Output: ${hooksDir}/`);
|
console.log(` Output: ${hooksDir}/`);
|
||||||
console.log(` - Worker: worker-service.cjs`);
|
console.log(` - Worker: worker-service.cjs`);
|
||||||
console.log(` - MCP Server: mcp-server.cjs`);
|
console.log(` - MCP Server: mcp-server.cjs`);
|
||||||
console.log(` - Context Generator: context-generator.cjs`);
|
console.log(` - Context Generator: context-generator.cjs`);
|
||||||
|
console.log(` Output: ${npxCliOutDir}/`);
|
||||||
|
console.log(` - NPX CLI: index.js`);
|
||||||
|
if (fs.existsSync('openclaw/dist/index.js')) {
|
||||||
|
console.log(` Output: openclaw/dist/`);
|
||||||
|
console.log(` - OpenClaw Plugin: index.js`);
|
||||||
|
}
|
||||||
|
if (fs.existsSync('dist/opencode-plugin/index.js')) {
|
||||||
|
console.log(` Output: dist/opencode-plugin/`);
|
||||||
|
console.log(` - OpenCode Plugin: index.js`);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('\n❌ Build failed:', error.message);
|
console.error('\n❌ Build failed:', error.message);
|
||||||
|
|||||||
Executable
+181
@@ -0,0 +1,181 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# claude-mem-sync — Synchronize claude-mem observations between machines
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# claude-mem-sync push <remote-host> # local → remote
|
||||||
|
# claude-mem-sync pull <remote-host> # remote → local
|
||||||
|
# claude-mem-sync sync <remote-host> # bidirectional (push + pull)
|
||||||
|
# claude-mem-sync status <remote-host> # compare counts
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - SSH access to remote host (key-based auth recommended)
|
||||||
|
# - Python 3 on both machines
|
||||||
|
# - claude-mem installed on both machines (~/.claude-mem/claude-mem.db)
|
||||||
|
#
|
||||||
|
# Environment variables:
|
||||||
|
# CLAUDE_MEM_DB Local database path (default: ~/.claude-mem/claude-mem.db)
|
||||||
|
# CLAUDE_MEM_REMOTE_DB Remote database path (default: ~/.claude-mem/claude-mem.db)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOCAL_DB="${CLAUDE_MEM_DB:-$HOME/.claude-mem/claude-mem.db}"
|
||||||
|
COMMAND="${1:?Usage: claude-mem-sync <push|pull|sync|status> <remote-host>}"
|
||||||
|
REMOTE_HOST="${2:?Missing remote host. Usage: claude-mem-sync $COMMAND <remote-host>}"
|
||||||
|
REMOTE_DB="${CLAUDE_MEM_REMOTE_DB:-\$HOME/.claude-mem/claude-mem.db}"
|
||||||
|
TMPDIR="/tmp/claude-mem-sync-$$"
|
||||||
|
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
trap "rm -rf $TMPDIR" EXIT
|
||||||
|
|
||||||
|
# Column lists for observations and session_summaries
|
||||||
|
OBS_COLS="memory_session_id,project,text,type,title,subtitle,facts,narrative,concepts,files_read,files_modified,prompt_number,discovery_tokens,created_at,created_at_epoch"
|
||||||
|
SUM_COLS="memory_session_id,project,request,investigated,learned,completed,next_steps,files_read,files_edited,notes,prompt_number,discovery_tokens,created_at,created_at_epoch"
|
||||||
|
|
||||||
|
export_obs() {
|
||||||
|
local db="$1" output="$2"
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3, json, sys
|
||||||
|
conn = sqlite3.connect('$db')
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('''SELECT $OBS_COLS FROM observations ORDER BY created_at''')
|
||||||
|
cols = '$OBS_COLS'.split(',')
|
||||||
|
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||||
|
cur.execute('''SELECT $SUM_COLS FROM session_summaries ORDER BY created_at''')
|
||||||
|
cols2 = '$SUM_COLS'.split(',')
|
||||||
|
sums = [dict(zip(cols2, r)) for r in cur.fetchall()]
|
||||||
|
json.dump({'observations': rows, 'summaries': sums}, open('$output', 'w'))
|
||||||
|
print(f'{len(rows)} obs, {len(sums)} sums exported', file=sys.stderr)
|
||||||
|
conn.close()
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
import_obs() {
|
||||||
|
local db="$1" input="$2"
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3, json, sys
|
||||||
|
conn = sqlite3.connect('$db')
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT created_at, title FROM observations')
|
||||||
|
existing = set((r[0],r[1]) for r in cur.fetchall())
|
||||||
|
cur.execute('SELECT created_at, request FROM session_summaries')
|
||||||
|
existing_s = set((r[0],r[1]) for r in cur.fetchall())
|
||||||
|
data = json.load(open('$input'))
|
||||||
|
oi, si = 0, 0
|
||||||
|
obs_cols = '$OBS_COLS'.split(',')
|
||||||
|
sum_cols = '$SUM_COLS'.split(',')
|
||||||
|
obs_placeholders = ','.join(['?'] * len(obs_cols))
|
||||||
|
sum_placeholders = ','.join(['?'] * len(sum_cols))
|
||||||
|
for o in data['observations']:
|
||||||
|
if (o['created_at'], o['title']) not in existing:
|
||||||
|
cur.execute(f'INSERT INTO observations ($OBS_COLS) VALUES ({obs_placeholders})',
|
||||||
|
tuple(o[k] for k in obs_cols))
|
||||||
|
oi += 1
|
||||||
|
for s in data['summaries']:
|
||||||
|
if (s['created_at'], s['request']) not in existing_s:
|
||||||
|
cur.execute(f'INSERT INTO session_summaries ($SUM_COLS) VALUES ({sum_placeholders})',
|
||||||
|
tuple(s[k] for k in sum_cols))
|
||||||
|
si += 1
|
||||||
|
conn.commit()
|
||||||
|
print(f'{oi} new obs, {si} new sums imported', file=sys.stderr)
|
||||||
|
conn.close()
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
count_db() {
|
||||||
|
local db="$1"
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('$db')
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT COUNT(*) FROM observations')
|
||||||
|
obs = cur.fetchone()[0]
|
||||||
|
cur.execute('SELECT COUNT(*) FROM session_summaries')
|
||||||
|
sums = cur.fetchone()[0]
|
||||||
|
cur.execute('SELECT MAX(created_at) FROM observations')
|
||||||
|
last = cur.fetchone()[0] or 'empty'
|
||||||
|
print(f'{obs} obs, {sums} sums (last: {last[:19]})')
|
||||||
|
conn.close()
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$COMMAND" in
|
||||||
|
push)
|
||||||
|
echo "=== Push: local → $REMOTE_HOST ==="
|
||||||
|
export_obs "$LOCAL_DB" "$TMPDIR/export.json"
|
||||||
|
scp -q "$TMPDIR/export.json" "$REMOTE_HOST:/tmp/mem-import.json"
|
||||||
|
# Run import on remote
|
||||||
|
ssh "$REMOTE_HOST" "python3 -c \"
|
||||||
|
import sqlite3, json, sys
|
||||||
|
conn = sqlite3.connect('$REMOTE_DB')
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT created_at, title FROM observations')
|
||||||
|
existing = set((r[0],r[1]) for r in cur.fetchall())
|
||||||
|
cur.execute('SELECT created_at, request FROM session_summaries')
|
||||||
|
existing_s = set((r[0],r[1]) for r in cur.fetchall())
|
||||||
|
data = json.load(open('/tmp/mem-import.json'))
|
||||||
|
obs_cols = '$OBS_COLS'.split(',')
|
||||||
|
sum_cols = '$SUM_COLS'.split(',')
|
||||||
|
obs_ph = ','.join(['?'] * len(obs_cols))
|
||||||
|
sum_ph = ','.join(['?'] * len(sum_cols))
|
||||||
|
oi, si = 0, 0
|
||||||
|
for o in data['observations']:
|
||||||
|
if (o['created_at'], o['title']) not in existing:
|
||||||
|
cur.execute(f'INSERT INTO observations ($OBS_COLS) VALUES ({obs_ph})', tuple(o[k] for k in obs_cols))
|
||||||
|
oi += 1
|
||||||
|
for s in data['summaries']:
|
||||||
|
if (s['created_at'], s['request']) not in existing_s:
|
||||||
|
cur.execute(f'INSERT INTO session_summaries ($SUM_COLS) VALUES ({sum_ph})', tuple(s[k] for k in sum_cols))
|
||||||
|
si += 1
|
||||||
|
conn.commit()
|
||||||
|
print(f'Remote: {oi} new obs, {si} new sums imported', file=sys.stderr)
|
||||||
|
conn.close()
|
||||||
|
\""
|
||||||
|
;;
|
||||||
|
pull)
|
||||||
|
echo "=== Pull: $REMOTE_HOST → local ==="
|
||||||
|
ssh "$REMOTE_HOST" "python3 -c \"
|
||||||
|
import sqlite3, json
|
||||||
|
conn = sqlite3.connect('$REMOTE_DB')
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT $OBS_COLS FROM observations ORDER BY created_at')
|
||||||
|
cols = '$OBS_COLS'.split(',')
|
||||||
|
obs = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||||
|
cur.execute('SELECT $SUM_COLS FROM session_summaries ORDER BY created_at')
|
||||||
|
cols2 = '$SUM_COLS'.split(',')
|
||||||
|
sums = [dict(zip(cols2, r)) for r in cur.fetchall()]
|
||||||
|
json.dump({'observations': obs, 'summaries': sums}, open('/tmp/mem-export.json', 'w'))
|
||||||
|
print(f'{len(obs)} obs, {len(sums)} sums exported')
|
||||||
|
conn.close()
|
||||||
|
\""
|
||||||
|
scp -q "$REMOTE_HOST:/tmp/mem-export.json" "$TMPDIR/import.json"
|
||||||
|
import_obs "$LOCAL_DB" "$TMPDIR/import.json"
|
||||||
|
;;
|
||||||
|
sync)
|
||||||
|
echo "=== Bidirectional sync with $REMOTE_HOST ==="
|
||||||
|
"$0" push "$REMOTE_HOST"
|
||||||
|
"$0" pull "$REMOTE_HOST"
|
||||||
|
"$0" status "$REMOTE_HOST"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
echo "=== Local ==="
|
||||||
|
count_db "$LOCAL_DB"
|
||||||
|
echo "=== Remote ($REMOTE_HOST) ==="
|
||||||
|
ssh "$REMOTE_HOST" "python3 -c \"
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('$REMOTE_DB')
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT COUNT(*) FROM observations')
|
||||||
|
obs = cur.fetchone()[0]
|
||||||
|
cur.execute('SELECT COUNT(*) FROM session_summaries')
|
||||||
|
sums = cur.fetchone()[0]
|
||||||
|
cur.execute('SELECT MAX(created_at) FROM observations')
|
||||||
|
last = cur.fetchone()[0] or 'empty'
|
||||||
|
print(f'{obs} obs, {sums} sums (last: {last[:19]})')
|
||||||
|
conn.close()
|
||||||
|
\""
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: claude-mem-sync <push|pull|sync|status> <remote-host>"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -3,6 +3,7 @@ import { claudeCodeAdapter } from './claude-code.js';
|
|||||||
import { cursorAdapter } from './cursor.js';
|
import { cursorAdapter } from './cursor.js';
|
||||||
import { geminiCliAdapter } from './gemini-cli.js';
|
import { geminiCliAdapter } from './gemini-cli.js';
|
||||||
import { rawAdapter } from './raw.js';
|
import { rawAdapter } from './raw.js';
|
||||||
|
import { windsurfAdapter } from './windsurf.js';
|
||||||
|
|
||||||
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
@@ -10,10 +11,11 @@ export function getPlatformAdapter(platform: string): PlatformAdapter {
|
|||||||
case 'cursor': return cursorAdapter;
|
case 'cursor': return cursorAdapter;
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
case 'gemini-cli': return geminiCliAdapter;
|
case 'gemini-cli': return geminiCliAdapter;
|
||||||
|
case 'windsurf': return windsurfAdapter;
|
||||||
case 'raw': return rawAdapter;
|
case 'raw': return rawAdapter;
|
||||||
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
|
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
|
||||||
default: return rawAdapter;
|
default: return rawAdapter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter };
|
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
|
||||||
|
|
||||||
|
// Maps Windsurf stdin format — JSON envelope with agent_action_name + tool_info payload
|
||||||
|
//
|
||||||
|
// Common envelope (all hooks):
|
||||||
|
// { agent_action_name, trajectory_id, execution_id, timestamp, tool_info: { ... } }
|
||||||
|
//
|
||||||
|
// Event-specific tool_info payloads:
|
||||||
|
// pre_user_prompt: { user_prompt: string }
|
||||||
|
// post_write_code: { file_path, edits: [{ old_string, new_string }] }
|
||||||
|
// post_run_command: { command_line, cwd }
|
||||||
|
// post_mcp_tool_use: { mcp_server_name, mcp_tool_name, mcp_tool_arguments, mcp_result }
|
||||||
|
// post_cascade_response: { response }
|
||||||
|
export const windsurfAdapter: PlatformAdapter = {
|
||||||
|
normalizeInput(raw) {
|
||||||
|
const r = (raw ?? {}) as any;
|
||||||
|
const toolInfo = r.tool_info ?? {};
|
||||||
|
const actionName: string = r.agent_action_name ?? '';
|
||||||
|
|
||||||
|
const base: NormalizedHookInput = {
|
||||||
|
sessionId: r.trajectory_id ?? r.execution_id,
|
||||||
|
cwd: toolInfo.cwd ?? process.cwd(),
|
||||||
|
platform: 'windsurf',
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (actionName) {
|
||||||
|
case 'pre_user_prompt':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
prompt: toolInfo.user_prompt,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'post_write_code':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toolName: 'Write',
|
||||||
|
filePath: toolInfo.file_path,
|
||||||
|
edits: toolInfo.edits,
|
||||||
|
toolInput: {
|
||||||
|
file_path: toolInfo.file_path,
|
||||||
|
edits: toolInfo.edits,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'post_run_command':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
cwd: toolInfo.cwd ?? base.cwd,
|
||||||
|
toolName: 'Bash',
|
||||||
|
toolInput: { command: toolInfo.command_line },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'post_mcp_tool_use':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toolName: toolInfo.mcp_tool_name ?? 'mcp_tool',
|
||||||
|
toolInput: toolInfo.mcp_tool_arguments,
|
||||||
|
toolResponse: toolInfo.mcp_result,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'post_cascade_response':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toolName: 'cascade_response',
|
||||||
|
toolResponse: toolInfo.response,
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown action — pass through what we can
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatOutput(result) {
|
||||||
|
// Windsurf exit codes: 0 = success, 2 = block (pre-hooks only)
|
||||||
|
// The CLI layer handles exit codes; here we just return a simple continue flag
|
||||||
|
return { continue: result.continue ?? true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -39,7 +39,7 @@ export const contextHandler: EventHandler = {
|
|||||||
// Pass all projects (parent + worktree if applicable) for unified timeline
|
// Pass all projects (parent + worktree if applicable) for unified timeline
|
||||||
const projectsParam = context.allProjects.join(',');
|
const projectsParam = context.allProjects.join(',');
|
||||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
||||||
const colorApiPath = `${apiPath}&colors=true`;
|
const colorApiPath = input.platform === 'claude-code' ? `${apiPath}&colors=true` : apiPath;
|
||||||
|
|
||||||
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
||||||
// Worker service has its own timeouts, so client-side timeout is redundant
|
// Worker service has its own timeouts, so client-side timeout is redundant
|
||||||
|
|||||||
@@ -87,17 +87,18 @@ export const sessionInitHandler: EventHandler = {
|
|||||||
|
|
||||||
// Skip SDK agent re-initialization if context was already injected for this session (#1079)
|
// Skip SDK agent re-initialization if context was already injected for this session (#1079)
|
||||||
// The prompt was already saved to the database by /api/sessions/init above —
|
// The prompt was already saved to the database by /api/sessions/init above —
|
||||||
// no need to re-start the SDK agent on every turn
|
// no need to re-start the SDK agent on every turn.
|
||||||
if (initResult.contextInjected) {
|
// Note: we do NOT return here — semantic injection below must run on every prompt.
|
||||||
|
const skipAgentInit = Boolean(initResult.contextInjected);
|
||||||
|
if (skipAgentInit) {
|
||||||
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | skipped_agent_init=true | reason=context_already_injected`, {
|
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | skipped_agent_init=true | reason=context_already_injected`, {
|
||||||
sessionId: sessionDbId
|
sessionId: sessionDbId
|
||||||
});
|
});
|
||||||
return { continue: true, suppressOutput: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only initialize SDK agent for Claude Code (not Cursor)
|
// Only initialize SDK agent for Claude Code (not Cursor)
|
||||||
// Cursor doesn't use the SDK agent - it only needs session/observation storage
|
// Cursor doesn't use the SDK agent - it only needs session/observation storage
|
||||||
if (input.platform !== 'cursor' && sessionDbId) {
|
if (!skipAgentInit && input.platform !== 'cursor' && sessionDbId) {
|
||||||
// Strip leading slash from commands for memory agent
|
// Strip leading slash from commands for memory agent
|
||||||
// /review 101 -> review 101 (more semantic for observations)
|
// /review 101 -> review 101 (more semantic for observations)
|
||||||
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
|
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
|
||||||
@@ -115,14 +116,58 @@ export const sessionInitHandler: EventHandler = {
|
|||||||
// Log but don't throw - SDK agent failure should not block the user's prompt
|
// Log but don't throw - SDK agent failure should not block the user's prompt
|
||||||
logger.failure('HOOK', `SDK agent start failed: ${response.status}`, { sessionDbId, promptNumber });
|
logger.failure('HOOK', `SDK agent start failed: ${response.status}`, { sessionDbId, promptNumber });
|
||||||
}
|
}
|
||||||
} else if (input.platform === 'cursor') {
|
} else if (!skipAgentInit && input.platform === 'cursor') {
|
||||||
logger.debug('HOOK', 'session-init: Skipping SDK agent init for Cursor platform', { sessionDbId, promptNumber });
|
logger.debug('HOOK', 'session-init: Skipping SDK agent init for Cursor platform', { sessionDbId, promptNumber });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Semantic context injection: query Chroma for relevant past observations
|
||||||
|
// and inject as additionalContext so Claude receives relevant memory each prompt.
|
||||||
|
// Controlled by CLAUDE_MEM_SEMANTIC_INJECT setting (default: true).
|
||||||
|
const semanticInject =
|
||||||
|
String(settings.CLAUDE_MEM_SEMANTIC_INJECT).toLowerCase() === 'true';
|
||||||
|
let additionalContext = '';
|
||||||
|
|
||||||
|
if (semanticInject && prompt && prompt.length >= 20 && prompt !== '[media prompt]') {
|
||||||
|
try {
|
||||||
|
const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5';
|
||||||
|
const semanticRes = await workerHttpRequest('/api/context/semantic', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ q: prompt, project, limit })
|
||||||
|
});
|
||||||
|
if (semanticRes.ok) {
|
||||||
|
const data = await semanticRes.json() as { context: string; count: number };
|
||||||
|
if (data.context) {
|
||||||
|
additionalContext = data.context;
|
||||||
|
logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, {
|
||||||
|
sessionId: sessionDbId, count: data.count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Graceful degradation — semantic injection is optional
|
||||||
|
logger.debug('HOOK', 'Semantic injection unavailable', {
|
||||||
|
error: e instanceof Error ? e.message : String(e)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | project=${project}`, {
|
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | project=${project}`, {
|
||||||
sessionId: sessionDbId
|
sessionId: sessionDbId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Return with semantic context if available
|
||||||
|
if (additionalContext) {
|
||||||
|
return {
|
||||||
|
continue: true,
|
||||||
|
suppressOutput: true,
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'UserPromptSubmit',
|
||||||
|
additionalContext
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { continue: true, suppressOutput: true };
|
return { continue: true, suppressOutput: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Summarize Handler - Stop
|
* Summarize Handler - Stop
|
||||||
*
|
*
|
||||||
* Extracted from summary-hook.ts - sends summary request to worker.
|
* Runs in the Stop hook (120s timeout, not capped like SessionEnd).
|
||||||
* Transcript parsing stays in the hook because only the hook has access to
|
* This is the ONLY place where we can reliably wait for async work.
|
||||||
* the transcript file path.
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Queue summarize request to worker
|
||||||
|
* 2. Poll worker until summary processing completes
|
||||||
|
* 3. Call /api/sessions/complete to clean up session
|
||||||
|
*
|
||||||
|
* SessionEnd (1.5s cap from Claude Code) is just a lightweight fallback —
|
||||||
|
* all real work must happen here in Stop.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||||
@@ -13,6 +20,8 @@ import { extractLastMessage } from '../../shared/transcript-parser.js';
|
|||||||
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
||||||
|
|
||||||
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
|
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
|
||||||
|
const POLL_INTERVAL_MS = 500;
|
||||||
|
const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120s timeout
|
||||||
|
|
||||||
export const summarizeHandler: EventHandler = {
|
export const summarizeHandler: EventHandler = {
|
||||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||||
@@ -43,11 +52,21 @@ export const summarizeHandler: EventHandler = {
|
|||||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip summary if transcript has no assistant message (prevents repeated
|
||||||
|
// empty summarize requests that pollute logs — upstream bug)
|
||||||
|
if (!lastAssistantMessage || !lastAssistantMessage.trim()) {
|
||||||
|
logger.debug('HOOK', 'No assistant message in transcript - skipping summary', {
|
||||||
|
sessionId,
|
||||||
|
transcriptPath
|
||||||
|
});
|
||||||
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||||
|
}
|
||||||
|
|
||||||
logger.dataIn('HOOK', 'Stop: Requesting summary', {
|
logger.dataIn('HOOK', 'Stop: Requesting summary', {
|
||||||
hasLastAssistantMessage: !!lastAssistantMessage
|
hasLastAssistantMessage: !!lastAssistantMessage
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send to worker - worker handles privacy check and database operations
|
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
|
||||||
const response = await workerHttpRequest('/api/sessions/summarize', {
|
const response = await workerHttpRequest('/api/sessions/summarize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -59,11 +78,49 @@ export const summarizeHandler: EventHandler = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Return standard response even on failure (matches original behavior)
|
|
||||||
return { continue: true, suppressOutput: true };
|
return { continue: true, suppressOutput: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('HOOK', 'Summary request sent successfully');
|
logger.debug('HOOK', 'Summary request queued, waiting for completion');
|
||||||
|
|
||||||
|
// 2. Poll worker until pending work for this session is done.
|
||||||
|
// This keeps the Stop hook alive (120s timeout) so the SDK agent
|
||||||
|
// can finish processing the summary before SessionEnd kills the session.
|
||||||
|
const waitStart = Date.now();
|
||||||
|
while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
try {
|
||||||
|
const statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, {
|
||||||
|
timeoutMs: 5000
|
||||||
|
});
|
||||||
|
if (statusResponse.ok) {
|
||||||
|
const status = await statusResponse.json() as { queueLength?: number };
|
||||||
|
if ((status.queueLength ?? 0) === 0) {
|
||||||
|
logger.info('HOOK', 'Summary processing complete', {
|
||||||
|
waitedMs: Date.now() - waitStart
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Worker may be busy — keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Complete the session — clean up active sessions map.
|
||||||
|
// This runs here in Stop (120s timeout) instead of SessionEnd (1.5s cap)
|
||||||
|
// so it reliably fires after summary work is done.
|
||||||
|
try {
|
||||||
|
await workerHttpRequest('/api/sessions/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contentSessionId: sessionId }),
|
||||||
|
timeoutMs: 10_000
|
||||||
|
});
|
||||||
|
logger.info('HOOK', 'Session completed in Stop hook', { contentSessionId: sessionId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('HOOK', `Stop hook: session-complete failed: ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
|
||||||
return { continue: true, suppressOutput: true };
|
return { continue: true, suppressOutput: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ export const userMessageHandler: EventHandler = {
|
|||||||
const project = basename(input.cwd ?? process.cwd());
|
const project = basename(input.cwd ?? process.cwd());
|
||||||
|
|
||||||
// Fetch formatted context directly from worker API
|
// Fetch formatted context directly from worker API
|
||||||
|
// Only request ANSI colors for platforms that render them (claude-code)
|
||||||
|
const colorsParam = input.platform === 'claude-code' ? '&colors=true' : '';
|
||||||
try {
|
try {
|
||||||
const response = await workerHttpRequest(
|
const response = await workerHttpRequest(
|
||||||
`/api/context/inject?project=${encodeURIComponent(project)}&colors=true`
|
`/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
export interface NormalizedHookInput {
|
export interface NormalizedHookInput {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
platform?: string; // 'claude-code' or 'cursor'
|
platform?: string; // 'claude-code', 'cursor', 'gemini-cli', etc.
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
toolInput?: unknown;
|
toolInput?: unknown;
|
||||||
@@ -10,6 +10,8 @@ export interface NormalizedHookInput {
|
|||||||
// Cursor-specific fields
|
// Cursor-specific fields
|
||||||
filePath?: string; // afterFileEdit
|
filePath?: string; // afterFileEdit
|
||||||
edits?: unknown[]; // afterFileEdit
|
edits?: unknown[]; // afterFileEdit
|
||||||
|
// Platform-specific metadata (source, reason, trigger, mcp_context, etc.)
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HookResult {
|
export interface HookResult {
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* OpenCode Plugin for claude-mem
|
||||||
|
*
|
||||||
|
* Integrates claude-mem persistent memory with OpenCode (110k+ stars).
|
||||||
|
* Runs inside OpenCode's Bun-based plugin runtime.
|
||||||
|
*
|
||||||
|
* Plugin hooks:
|
||||||
|
* - tool.execute.after: Captures tool execution observations
|
||||||
|
* - Bus events: session.created, message.updated, session.compacted,
|
||||||
|
* file.edited, session.deleted
|
||||||
|
*
|
||||||
|
* Custom tool:
|
||||||
|
* - claude_mem_search: Search memory database from within OpenCode
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Minimal type declarations for OpenCode Plugin SDK
|
||||||
|
// These match the runtime API provided by @opencode-ai/plugin
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface OpenCodeProject {
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenCodePluginContext {
|
||||||
|
client: unknown;
|
||||||
|
project: OpenCodeProject;
|
||||||
|
directory: string;
|
||||||
|
worktree: string;
|
||||||
|
serverUrl: URL;
|
||||||
|
$: unknown; // BunShell
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolExecuteAfterInput {
|
||||||
|
tool: string;
|
||||||
|
sessionID: string;
|
||||||
|
callID: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolExecuteAfterOutput {
|
||||||
|
title: string;
|
||||||
|
output: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolDefinition {
|
||||||
|
description: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
execute: (args: Record<string, unknown>, context: unknown) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bus event payloads
|
||||||
|
interface SessionCreatedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
directory?: string;
|
||||||
|
project?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageUpdatedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCompactedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
summary?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileEditedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
path: string;
|
||||||
|
diff?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionDeletedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const WORKER_BASE_URL = "http://127.0.0.1:37777";
|
||||||
|
const MAX_TOOL_RESPONSE_LENGTH = 1000;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Worker HTTP Client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function workerPost(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (await response.json()) as Record<string, unknown>;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Gracefully handle ECONNREFUSED — worker may not be running
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("ECONNREFUSED")) {
|
||||||
|
console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerPostFireAndForget(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
fetch(`${WORKER_BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("ECONNREFUSED")) {
|
||||||
|
console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workerGetText(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_BASE_URL}${path}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("ECONNREFUSED")) {
|
||||||
|
console.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session tracking
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const contentSessionIdsByOpenCodeSessionId = new Map<string, string>();
|
||||||
|
|
||||||
|
const MAX_SESSION_MAP_ENTRIES = 1000;
|
||||||
|
|
||||||
|
function getOrCreateContentSessionId(openCodeSessionId: string): string {
|
||||||
|
if (!contentSessionIdsByOpenCodeSessionId.has(openCodeSessionId)) {
|
||||||
|
// Evict oldest entries when the map exceeds the cap (Map preserves insertion order)
|
||||||
|
while (contentSessionIdsByOpenCodeSessionId.size >= MAX_SESSION_MAP_ENTRIES) {
|
||||||
|
const oldestKey = contentSessionIdsByOpenCodeSessionId.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) {
|
||||||
|
contentSessionIdsByOpenCodeSessionId.delete(oldestKey);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentSessionIdsByOpenCodeSessionId.set(
|
||||||
|
openCodeSessionId,
|
||||||
|
`opencode-${openCodeSessionId}-${Date.now()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return contentSessionIdsByOpenCodeSessionId.get(openCodeSessionId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin Entry Point
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => {
|
||||||
|
const projectName = ctx.project?.name || "opencode";
|
||||||
|
|
||||||
|
console.log(`[claude-mem] OpenCode plugin loading (project: ${projectName})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Direct interceptor hooks
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
hooks: {
|
||||||
|
tool: {
|
||||||
|
execute: {
|
||||||
|
after: (
|
||||||
|
input: ToolExecuteAfterInput,
|
||||||
|
output: ToolExecuteAfterOutput,
|
||||||
|
) => {
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(input.sessionID);
|
||||||
|
|
||||||
|
// Truncate long tool output
|
||||||
|
let toolResponseText = output.output || "";
|
||||||
|
if (toolResponseText.length > MAX_TOOL_RESPONSE_LENGTH) {
|
||||||
|
toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/observations", {
|
||||||
|
contentSessionId,
|
||||||
|
tool_name: input.tool,
|
||||||
|
tool_input: input.args || {},
|
||||||
|
tool_response: toolResponseText,
|
||||||
|
cwd: ctx.directory,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Bus event handlers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
event: (eventName: string, payload: unknown) => {
|
||||||
|
switch (eventName) {
|
||||||
|
case "session.created": {
|
||||||
|
const { event } = payload as SessionCreatedEvent;
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/init", {
|
||||||
|
contentSessionId,
|
||||||
|
project: projectName,
|
||||||
|
prompt: "",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.updated": {
|
||||||
|
const { event } = payload as MessageUpdatedEvent;
|
||||||
|
|
||||||
|
// Only capture assistant messages as observations
|
||||||
|
if (event.role !== "assistant") break;
|
||||||
|
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||||
|
|
||||||
|
let messageText = event.content || "";
|
||||||
|
if (messageText.length > MAX_TOOL_RESPONSE_LENGTH) {
|
||||||
|
messageText = messageText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/observations", {
|
||||||
|
contentSessionId,
|
||||||
|
tool_name: "assistant_message",
|
||||||
|
tool_input: {},
|
||||||
|
tool_response: messageText,
|
||||||
|
cwd: ctx.directory,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.compacted": {
|
||||||
|
const { event } = payload as SessionCompactedEvent;
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/summarize", {
|
||||||
|
contentSessionId,
|
||||||
|
last_assistant_message: event.summary || "",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file.edited": {
|
||||||
|
const { event } = payload as FileEditedEvent;
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/observations", {
|
||||||
|
contentSessionId,
|
||||||
|
tool_name: "file_edit",
|
||||||
|
tool_input: { path: event.path },
|
||||||
|
tool_response: event.diff
|
||||||
|
? event.diff.slice(0, MAX_TOOL_RESPONSE_LENGTH)
|
||||||
|
: `File edited: ${event.path}`,
|
||||||
|
cwd: ctx.directory,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.deleted": {
|
||||||
|
const { event } = payload as SessionDeletedEvent;
|
||||||
|
const contentSessionId = contentSessionIdsByOpenCodeSessionId.get(
|
||||||
|
event.sessionID,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contentSessionId) {
|
||||||
|
workerPostFireAndForget("/api/sessions/complete", {
|
||||||
|
contentSessionId,
|
||||||
|
});
|
||||||
|
contentSessionIdsByOpenCodeSessionId.delete(event.sessionID);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Custom tools
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
tool: {
|
||||||
|
claude_mem_search: {
|
||||||
|
description:
|
||||||
|
"Search claude-mem memory database for past observations, sessions, and context",
|
||||||
|
args: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Search query for memory observations",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
const query = String(args.query || "");
|
||||||
|
if (!query) {
|
||||||
|
return "Please provide a search query.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await workerGetText(
|
||||||
|
`/api/search/observations?query=${encodeURIComponent(query)}&limit=10`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return "claude-mem worker is not running. Start it with: npx claude-mem start";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
return `No results found for "${query}".`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((item: Record<string, unknown>, index: number) => {
|
||||||
|
const title = String(item.title || item.subtitle || "Untitled");
|
||||||
|
const project = item.project ? ` [${String(item.project)}]` : "";
|
||||||
|
return `${index + 1}. ${title}${project}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
} catch {
|
||||||
|
return "Failed to parse search results.";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} satisfies ToolDefinition,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClaudeMemPlugin;
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* IDE Auto-Detection
|
||||||
|
*
|
||||||
|
* Detects which AI coding IDEs / tools are installed on the system by
|
||||||
|
* probing known config directories and checking for binaries in PATH.
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, readdirSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { IS_WINDOWS } from '../utils/paths.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IDE type and metadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface IDEInfo {
|
||||||
|
/** Machine-readable identifier. */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable label for display in prompts. */
|
||||||
|
label: string;
|
||||||
|
/** Whether the IDE was detected on this system. */
|
||||||
|
detected: boolean;
|
||||||
|
/** Whether claude-mem has implemented setup for this IDE. */
|
||||||
|
supported: boolean;
|
||||||
|
/** Short hint text shown in the multi-select. */
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATH helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isCommandInPath(command: string): boolean {
|
||||||
|
try {
|
||||||
|
const whichCommand = IS_WINDOWS ? 'where' : 'which';
|
||||||
|
execSync(`${whichCommand} ${command}`, { stdio: 'pipe' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VS Code extension directory scanner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function hasVscodeExtension(extensionNameFragment: string): boolean {
|
||||||
|
const extensionsDirectory = join(homedir(), '.vscode', 'extensions');
|
||||||
|
if (!existsSync(extensionsDirectory)) return false;
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(extensionsDirectory);
|
||||||
|
return entries.some((entry) => entry.toLowerCase().includes(extensionNameFragment.toLowerCase()));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detection map
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect all known IDEs and return an array of `IDEInfo` objects.
|
||||||
|
* Each entry indicates whether the IDE was found and whether claude-mem
|
||||||
|
* currently supports setting it up.
|
||||||
|
*/
|
||||||
|
export function detectInstalledIDEs(): IDEInfo[] {
|
||||||
|
const home = homedir();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'claude-code',
|
||||||
|
label: 'Claude Code',
|
||||||
|
detected: existsSync(join(home, '.claude')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'recommended',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-cli',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
detected: existsSync(join(home, '.gemini')),
|
||||||
|
supported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode',
|
||||||
|
label: 'OpenCode',
|
||||||
|
detected:
|
||||||
|
existsSync(join(home, '.config', 'opencode')) || isCommandInPath('opencode'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'plugin-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openclaw',
|
||||||
|
label: 'OpenClaw',
|
||||||
|
detected: existsSync(join(home, '.openclaw')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'plugin-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windsurf',
|
||||||
|
label: 'Windsurf',
|
||||||
|
detected: existsSync(join(home, '.codeium', 'windsurf')),
|
||||||
|
supported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'codex-cli',
|
||||||
|
label: 'Codex CLI',
|
||||||
|
detected: existsSync(join(home, '.codex')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'transcript-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cursor',
|
||||||
|
label: 'Cursor',
|
||||||
|
detected: existsSync(join(home, '.cursor')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'hooks + MCP integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copilot-cli',
|
||||||
|
label: 'Copilot CLI',
|
||||||
|
detected: isCommandInPath('copilot'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'antigravity',
|
||||||
|
label: 'Antigravity',
|
||||||
|
detected: existsSync(join(home, '.gemini', 'antigravity')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'goose',
|
||||||
|
label: 'Goose',
|
||||||
|
detected:
|
||||||
|
existsSync(join(home, '.config', 'goose')) || isCommandInPath('goose'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crush',
|
||||||
|
label: 'Crush',
|
||||||
|
detected: isCommandInPath('crush'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roo-code',
|
||||||
|
label: 'Roo Code',
|
||||||
|
detected: hasVscodeExtension('roo-code'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warp',
|
||||||
|
label: 'Warp',
|
||||||
|
detected: existsSync(join(home, '.warp')) || isCommandInPath('warp'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return only the IDEs that were detected on this system.
|
||||||
|
*/
|
||||||
|
export function getDetectedIDEs(): IDEInfo[] {
|
||||||
|
return detectInstalledIDEs().filter((ide) => ide.detected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
/**
|
||||||
|
* Install command for `npx claude-mem install`.
|
||||||
|
*
|
||||||
|
* Replaces the git-clone + build workflow. The npm package already ships
|
||||||
|
* a pre-built `plugin/` directory; this command copies it into the right
|
||||||
|
* locations and registers it with Claude Code.
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import * as p from '@clack/prompts';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { cpSync, existsSync, readFileSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// Non-TTY detection: @clack/prompts crashes with ENOENT in non-TTY environments
|
||||||
|
const isInteractive = process.stdin.isTTY === true;
|
||||||
|
|
||||||
|
/** Run a list of tasks, falling back to plain console.log when non-TTY */
|
||||||
|
interface TaskDescriptor {
|
||||||
|
title: string;
|
||||||
|
task: (message: (msg: string) => void) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTasks(tasks: TaskDescriptor[]): Promise<void> {
|
||||||
|
if (isInteractive) {
|
||||||
|
await p.tasks(tasks);
|
||||||
|
} else {
|
||||||
|
for (const t of tasks) {
|
||||||
|
const result = await t.task((msg: string) => console.log(` ${msg}`));
|
||||||
|
console.log(` ${result}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log helpers that fall back to console.log in non-TTY */
|
||||||
|
const log = {
|
||||||
|
info: (msg: string) => isInteractive ? p.log.info(msg) : console.log(` ${msg}`),
|
||||||
|
success: (msg: string) => isInteractive ? p.log.success(msg) : console.log(` ${msg}`),
|
||||||
|
warn: (msg: string) => isInteractive ? p.log.warn(msg) : console.warn(` ${msg}`),
|
||||||
|
error: (msg: string) => isInteractive ? p.log.error(msg) : console.error(` ${msg}`),
|
||||||
|
};
|
||||||
|
import {
|
||||||
|
claudeSettingsPath,
|
||||||
|
ensureDirectoryExists,
|
||||||
|
installedPluginsPath,
|
||||||
|
IS_WINDOWS,
|
||||||
|
knownMarketplacesPath,
|
||||||
|
marketplaceDirectory,
|
||||||
|
npmPackagePluginDirectory,
|
||||||
|
npmPackageRootDirectory,
|
||||||
|
pluginCacheDirectory,
|
||||||
|
pluginsDirectory,
|
||||||
|
readPluginVersion,
|
||||||
|
writeJsonFileAtomic,
|
||||||
|
} from '../utils/paths.js';
|
||||||
|
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
import { detectInstalledIDEs } from './ide-detection.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registration helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function registerMarketplace(): void {
|
||||||
|
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
|
||||||
|
|
||||||
|
knownMarketplaces['thedotmack'] = {
|
||||||
|
source: {
|
||||||
|
source: 'github',
|
||||||
|
repo: 'thedotmack/claude-mem',
|
||||||
|
},
|
||||||
|
installLocation: marketplaceDirectory(),
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
autoUpdate: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureDirectoryExists(pluginsDirectory());
|
||||||
|
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPlugin(version: string): void {
|
||||||
|
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
|
||||||
|
|
||||||
|
if (!installedPlugins.version) installedPlugins.version = 2;
|
||||||
|
if (!installedPlugins.plugins) installedPlugins.plugins = {};
|
||||||
|
|
||||||
|
const cachePath = pluginCacheDirectory(version);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
installedPlugins.plugins['claude-mem@thedotmack'] = [
|
||||||
|
{
|
||||||
|
scope: 'user',
|
||||||
|
installPath: cachePath,
|
||||||
|
version,
|
||||||
|
installedAt: now,
|
||||||
|
lastUpdated: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enablePluginInClaudeSettings(): void {
|
||||||
|
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
|
||||||
|
|
||||||
|
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
||||||
|
settings.enabledPlugins['claude-mem@thedotmack'] = true;
|
||||||
|
|
||||||
|
writeJsonFileAtomic(claudeSettingsPath(), settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IDE setup dispatcher
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns a list of IDE IDs that failed setup. */
|
||||||
|
async function setupIDEs(selectedIDEs: string[]): Promise<string[]> {
|
||||||
|
const failedIDEs: string[] = [];
|
||||||
|
|
||||||
|
for (const ideId of selectedIDEs) {
|
||||||
|
switch (ideId) {
|
||||||
|
case 'claude-code': {
|
||||||
|
// Claude Code uses its native plugin CLI — two commands handle
|
||||||
|
// marketplace registration, plugin installation, and enablement.
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
'claude plugin marketplace add thedotmack/claude-mem && claude plugin install claude-mem',
|
||||||
|
{ stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
log.success('Claude Code: plugin installed via CLI.');
|
||||||
|
} catch {
|
||||||
|
log.error('Claude Code: plugin install failed. Is `claude` CLI on your PATH?');
|
||||||
|
failedIDEs.push(ideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cursor': {
|
||||||
|
const { installCursorHooks, configureCursorMcp } = await import('../../services/integrations/CursorHooksInstaller.js');
|
||||||
|
const cursorResult = await installCursorHooks('user');
|
||||||
|
if (cursorResult === 0) {
|
||||||
|
const mcpResult = configureCursorMcp('user');
|
||||||
|
if (mcpResult === 0) {
|
||||||
|
log.success('Cursor: hooks + MCP installed.');
|
||||||
|
} else {
|
||||||
|
log.success('Cursor: hooks installed (MCP setup failed — run `npx claude-mem cursor mcp` to retry).');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error('Cursor: hook installation failed.');
|
||||||
|
failedIDEs.push(ideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'gemini-cli': {
|
||||||
|
const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
|
||||||
|
const geminiResult = await installGeminiCliHooks();
|
||||||
|
if (geminiResult === 0) {
|
||||||
|
log.success('Gemini CLI: hooks installed.');
|
||||||
|
} else {
|
||||||
|
log.error('Gemini CLI: hook installation failed.');
|
||||||
|
failedIDEs.push(ideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'opencode': {
|
||||||
|
const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js');
|
||||||
|
const openCodeResult = await installOpenCodeIntegration();
|
||||||
|
if (openCodeResult === 0) {
|
||||||
|
log.success('OpenCode: plugin installed.');
|
||||||
|
} else {
|
||||||
|
log.error('OpenCode: plugin installation failed.');
|
||||||
|
failedIDEs.push(ideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'windsurf': {
|
||||||
|
const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
|
||||||
|
const windsurfResult = await installWindsurfHooks();
|
||||||
|
if (windsurfResult === 0) {
|
||||||
|
log.success('Windsurf: hooks installed.');
|
||||||
|
} else {
|
||||||
|
log.error('Windsurf: hook installation failed.');
|
||||||
|
failedIDEs.push(ideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'openclaw': {
|
||||||
|
const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js');
|
||||||
|
const openClawResult = await installOpenClawIntegration();
|
||||||
|
if (openClawResult === 0) {
|
||||||
|
log.success('OpenClaw: plugin installed.');
|
||||||
|
} else {
|
||||||
|
log.error('OpenClaw: plugin installation failed.');
|
||||||
|
failedIDEs.push(ideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'codex-cli': {
|
||||||
|
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
|
||||||
|
const codexResult = await installCodexCli();
|
||||||
|
if (codexResult === 0) {
|
||||||
|
log.success('Codex CLI: transcript watching configured.');
|
||||||
|
} else {
|
||||||
|
log.error('Codex CLI: integration setup failed.');
|
||||||
|
failedIDEs.push(ideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'copilot-cli':
|
||||||
|
case 'antigravity':
|
||||||
|
case 'goose':
|
||||||
|
case 'crush':
|
||||||
|
case 'roo-code':
|
||||||
|
case 'warp': {
|
||||||
|
const { MCP_IDE_INSTALLERS } = await import('../../services/integrations/McpIntegrations.js');
|
||||||
|
const mcpInstaller = MCP_IDE_INSTALLERS[ideId];
|
||||||
|
if (mcpInstaller) {
|
||||||
|
const mcpResult = await mcpInstaller();
|
||||||
|
const allIDEs = detectInstalledIDEs();
|
||||||
|
const ideInfo = allIDEs.find((i) => i.id === ideId);
|
||||||
|
const ideLabel = ideInfo?.label ?? ideId;
|
||||||
|
if (mcpResult === 0) {
|
||||||
|
log.success(`${ideLabel}: MCP integration installed.`);
|
||||||
|
} else {
|
||||||
|
log.error(`${ideLabel}: MCP integration failed.`);
|
||||||
|
failedIDEs.push(ideId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const allIDEs = detectInstalledIDEs();
|
||||||
|
const ide = allIDEs.find((i) => i.id === ideId);
|
||||||
|
if (ide && !ide.supported) {
|
||||||
|
log.warn(`Support for ${ide.label} coming soon.`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failedIDEs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Interactive IDE selection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function promptForIDESelection(): Promise<string[]> {
|
||||||
|
const detectedIDEs = detectInstalledIDEs();
|
||||||
|
const detected = detectedIDEs.filter((ide) => ide.detected);
|
||||||
|
|
||||||
|
if (detected.length === 0) {
|
||||||
|
log.warn('No supported IDEs detected. Installing for Claude Code by default.');
|
||||||
|
return ['claude-code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = detected.map((ide) => ({
|
||||||
|
value: ide.id,
|
||||||
|
label: ide.label,
|
||||||
|
hint: ide.supported ? ide.hint : 'coming soon',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await p.multiselect({
|
||||||
|
message: 'Which IDEs do you use?',
|
||||||
|
options,
|
||||||
|
initialValues: detected
|
||||||
|
.filter((ide) => ide.supported)
|
||||||
|
.map((ide) => ide.id),
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(result)) {
|
||||||
|
p.cancel('Installation cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core copy logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function copyPluginToMarketplace(): void {
|
||||||
|
const marketplaceDir = marketplaceDirectory();
|
||||||
|
const packageRoot = npmPackageRootDirectory();
|
||||||
|
|
||||||
|
ensureDirectoryExists(marketplaceDir);
|
||||||
|
|
||||||
|
// Only copy directories/files that are actually needed at runtime.
|
||||||
|
// The npm package ships plugin/, package.json, node_modules/, openclaw/, dist/.
|
||||||
|
// When running from a dev checkout, the root contains many extra dirs
|
||||||
|
// (.claude, .agents, src, docs, etc.) that must NOT be copied.
|
||||||
|
const allowedTopLevelEntries = [
|
||||||
|
'plugin',
|
||||||
|
'package.json',
|
||||||
|
'package-lock.json',
|
||||||
|
'node_modules',
|
||||||
|
'openclaw',
|
||||||
|
'dist',
|
||||||
|
'LICENSE',
|
||||||
|
'README.md',
|
||||||
|
'CHANGELOG.md',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const entry of allowedTopLevelEntries) {
|
||||||
|
const sourcePath = join(packageRoot, entry);
|
||||||
|
const destPath = join(marketplaceDir, entry);
|
||||||
|
if (!existsSync(sourcePath)) continue;
|
||||||
|
|
||||||
|
// Clean replace: remove stale files from previous installs before copying
|
||||||
|
if (existsSync(destPath)) {
|
||||||
|
rmSync(destPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
cpSync(sourcePath, destPath, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPluginToCache(version: string): void {
|
||||||
|
const sourcePluginDirectory = npmPackagePluginDirectory();
|
||||||
|
const cachePath = pluginCacheDirectory(version);
|
||||||
|
|
||||||
|
// Clean replace: remove stale cache before copying
|
||||||
|
rmSync(cachePath, { recursive: true, force: true });
|
||||||
|
ensureDirectoryExists(cachePath);
|
||||||
|
cpSync(sourcePluginDirectory, cachePath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// npm install in marketplace dir
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function runNpmInstallInMarketplace(): void {
|
||||||
|
const marketplaceDir = marketplaceDirectory();
|
||||||
|
const packageJsonPath = join(marketplaceDir, 'package.json');
|
||||||
|
|
||||||
|
if (!existsSync(packageJsonPath)) return;
|
||||||
|
|
||||||
|
execSync('npm install --production', {
|
||||||
|
cwd: marketplaceDir,
|
||||||
|
stdio: 'pipe',
|
||||||
|
...(IS_WINDOWS ? { shell: true as const } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trigger smart-install for Bun / uv
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function runSmartInstall(): boolean {
|
||||||
|
const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js');
|
||||||
|
|
||||||
|
if (!existsSync(smartInstallPath)) {
|
||||||
|
log.warn('smart-install.js not found — skipping Bun/uv auto-install.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`node "${smartInstallPath}"`, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
...(IS_WINDOWS ? { shell: true as const } : {}),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface InstallOptions {
|
||||||
|
/** When provided, skip the interactive IDE multi-select and use this IDE. */
|
||||||
|
ide?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInstallCommand(options: InstallOptions = {}): Promise<void> {
|
||||||
|
const version = readPluginVersion();
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
|
p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
|
||||||
|
} else {
|
||||||
|
console.log('claude-mem install');
|
||||||
|
}
|
||||||
|
log.info(`Version: ${pc.cyan(version)}`);
|
||||||
|
log.info(`Platform: ${process.platform} (${process.arch})`);
|
||||||
|
|
||||||
|
// Check for existing installation
|
||||||
|
const marketplaceDir = marketplaceDirectory();
|
||||||
|
const alreadyInstalled = existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'));
|
||||||
|
|
||||||
|
if (alreadyInstalled) {
|
||||||
|
// Read existing version
|
||||||
|
try {
|
||||||
|
const existingPluginJson = JSON.parse(
|
||||||
|
readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'),
|
||||||
|
);
|
||||||
|
log.warn(`Existing installation detected (v${existingPluginJson.version ?? 'unknown'}).`);
|
||||||
|
} catch {
|
||||||
|
log.warn('Existing installation detected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
const shouldContinue = await p.confirm({
|
||||||
|
message: 'Overwrite existing installation?',
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(shouldContinue) || !shouldContinue) {
|
||||||
|
p.cancel('Installation cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDE selection
|
||||||
|
let selectedIDEs: string[];
|
||||||
|
if (options.ide) {
|
||||||
|
selectedIDEs = [options.ide];
|
||||||
|
const allIDEs = detectInstalledIDEs();
|
||||||
|
const match = allIDEs.find((i) => i.id === options.ide);
|
||||||
|
if (match && !match.supported) {
|
||||||
|
log.error(`Support for ${match.label} coming soon.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!match) {
|
||||||
|
log.error(`Unknown IDE: ${options.ide}`);
|
||||||
|
log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else if (process.stdin.isTTY) {
|
||||||
|
selectedIDEs = await promptForIDESelection();
|
||||||
|
} else {
|
||||||
|
// Non-interactive: default to claude-code
|
||||||
|
selectedIDEs = ['claude-code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-Claude-Code IDEs need the manual file copy / registration flow.
|
||||||
|
// Claude Code handles its own installation via `claude plugin install`.
|
||||||
|
const needsManualInstall = selectedIDEs.some((id) => id !== 'claude-code');
|
||||||
|
|
||||||
|
if (needsManualInstall) {
|
||||||
|
await runTasks([
|
||||||
|
{
|
||||||
|
title: 'Copying plugin files',
|
||||||
|
task: async (message) => {
|
||||||
|
message('Copying to marketplace directory...');
|
||||||
|
copyPluginToMarketplace();
|
||||||
|
return `Plugin files copied ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Caching plugin version',
|
||||||
|
task: async (message) => {
|
||||||
|
message(`Caching v${version}...`);
|
||||||
|
copyPluginToCache(version);
|
||||||
|
return `Plugin cached (v${version}) ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Registering marketplace',
|
||||||
|
task: async () => {
|
||||||
|
registerMarketplace();
|
||||||
|
return `Marketplace registered ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Registering plugin',
|
||||||
|
task: async () => {
|
||||||
|
registerPlugin(version);
|
||||||
|
return `Plugin registered ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Enabling plugin in Claude settings',
|
||||||
|
task: async () => {
|
||||||
|
enablePluginInClaudeSettings();
|
||||||
|
return `Plugin enabled ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Installing dependencies',
|
||||||
|
task: async (message) => {
|
||||||
|
message('Running npm install...');
|
||||||
|
try {
|
||||||
|
runNpmInstallInMarketplace();
|
||||||
|
return `Dependencies installed ${pc.green('OK')}`;
|
||||||
|
} catch {
|
||||||
|
return `Dependencies may need manual install ${pc.yellow('!')}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Setting up Bun and uv',
|
||||||
|
task: async (message) => {
|
||||||
|
message('Running smart-install...');
|
||||||
|
return runSmartInstall()
|
||||||
|
? `Runtime dependencies ready ${pc.green('OK')}`
|
||||||
|
: `Runtime setup may need attention ${pc.yellow('!')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDE-specific setup
|
||||||
|
const failedIDEs = await setupIDEs(selectedIDEs);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const installStatus = failedIDEs.length > 0 ? 'Installation Partial' : 'Installation Complete';
|
||||||
|
const summaryLines = [
|
||||||
|
`Version: ${pc.cyan(version)}`,
|
||||||
|
`Plugin dir: ${pc.cyan(marketplaceDir)}`,
|
||||||
|
`IDEs: ${pc.cyan(selectedIDEs.join(', '))}`,
|
||||||
|
];
|
||||||
|
if (failedIDEs.length > 0) {
|
||||||
|
summaryLines.push(`Failed: ${pc.red(failedIDEs.join(', '))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
|
p.note(summaryLines.join('\n'), installStatus);
|
||||||
|
} else {
|
||||||
|
console.log(`\n ${installStatus}`);
|
||||||
|
summaryLines.forEach(l => console.log(` ${l}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||||
|
const nextSteps = [
|
||||||
|
'Open Claude Code and start a conversation -- memory is automatic!',
|
||||||
|
`View your memories: ${pc.underline(`http://localhost:${workerPort}`)}`,
|
||||||
|
`Search past work: use ${pc.bold('/mem-search')} in Claude Code`,
|
||||||
|
`Start worker: ${pc.bold('npx claude-mem start')}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
|
p.note(nextSteps.join('\n'), 'Next Steps');
|
||||||
|
if (failedIDEs.length > 0) {
|
||||||
|
p.outro(pc.yellow('claude-mem installed with some IDE setup failures.'));
|
||||||
|
} else {
|
||||||
|
p.outro(pc.green('claude-mem installed successfully!'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\n Next Steps');
|
||||||
|
nextSteps.forEach(l => console.log(` ${l}`));
|
||||||
|
if (failedIDEs.length > 0) {
|
||||||
|
console.log('\nclaude-mem installed with some IDE setup failures.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log('\nclaude-mem installed successfully!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Runtime command routing for `npx claude-mem start|stop|restart|status|search|transcript`.
|
||||||
|
*
|
||||||
|
* These commands delegate to the installed plugin's worker-service.cjs via Bun,
|
||||||
|
* or hit the worker's HTTP API directly (for `search`).
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
import { resolveBunBinaryPath } from '../utils/bun-resolver.js';
|
||||||
|
import { isPluginInstalled, marketplaceDirectory } from '../utils/paths.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Installation guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ensureInstalledOrExit(): void {
|
||||||
|
if (!isPluginInstalled()) {
|
||||||
|
console.error(pc.red('claude-mem is not installed.'));
|
||||||
|
console.error(`Run: ${pc.bold('npx claude-mem install')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bun guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveBunOrExit(): string {
|
||||||
|
const bunPath = resolveBunBinaryPath();
|
||||||
|
if (!bunPath) {
|
||||||
|
console.error(pc.red('Bun not found.'));
|
||||||
|
console.error('Install Bun: https://bun.sh');
|
||||||
|
console.error('After installation, restart your terminal.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return bunPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Worker-service path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function workerServiceScriptPath(): string {
|
||||||
|
return join(marketplaceDirectory(), 'plugin', 'scripts', 'worker-service.cjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spawn helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function spawnBunWorkerCommand(command: string, extraArgs: string[] = []): void {
|
||||||
|
ensureInstalledOrExit();
|
||||||
|
const bunPath = resolveBunOrExit();
|
||||||
|
const workerScript = workerServiceScriptPath();
|
||||||
|
|
||||||
|
if (!existsSync(workerScript)) {
|
||||||
|
console.error(pc.red(`Worker script not found at: ${workerScript}`));
|
||||||
|
console.error('The installation may be corrupted. Try: npx claude-mem install');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [workerScript, command, ...extraArgs];
|
||||||
|
|
||||||
|
const child = spawn(bunPath, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: marketplaceDirectory(),
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(pc.red(`Failed to start Bun: ${error.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (exitCode) => {
|
||||||
|
process.exit(exitCode ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function runStartCommand(): void {
|
||||||
|
spawnBunWorkerCommand('start');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runStopCommand(): void {
|
||||||
|
spawnBunWorkerCommand('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runRestartCommand(): void {
|
||||||
|
spawnBunWorkerCommand('restart');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runStatusCommand(): void {
|
||||||
|
spawnBunWorkerCommand('status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the worker API at `GET /api/search?q=<query>`.
|
||||||
|
*/
|
||||||
|
export async function runSearchCommand(queryParts: string[]): Promise<void> {
|
||||||
|
ensureInstalledOrExit();
|
||||||
|
|
||||||
|
const query = queryParts.join(' ').trim();
|
||||||
|
if (!query) {
|
||||||
|
console.error(pc.red('Usage: npx claude-mem search <query>'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||||
|
const searchUrl = `http://127.0.0.1:${workerPort}/api/search?q=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(searchUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.error(pc.red('Search endpoint not found. Is the worker running?'));
|
||||||
|
console.error(`Try: ${pc.bold('npx claude-mem start')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.error(pc.red(`Search failed: HTTP ${response.status}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (typeof data === 'object' && data !== null) {
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.cause?.code === 'ECONNREFUSED' || error?.message?.includes('ECONNREFUSED')) {
|
||||||
|
console.error(pc.red('Worker is not running.'));
|
||||||
|
console.error(`Start it with: ${pc.bold('npx claude-mem start')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.error(pc.red(`Search failed: ${error.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the transcript watcher via Bun.
|
||||||
|
*/
|
||||||
|
export function runTranscriptWatchCommand(): void {
|
||||||
|
ensureInstalledOrExit();
|
||||||
|
const bunPath = resolveBunOrExit();
|
||||||
|
|
||||||
|
const transcriptWatcherPath = join(
|
||||||
|
marketplaceDirectory(),
|
||||||
|
'plugin',
|
||||||
|
'scripts',
|
||||||
|
'transcript-watcher.cjs',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existsSync(transcriptWatcherPath)) {
|
||||||
|
// Fall back to worker-service with transcript subcommand
|
||||||
|
spawnBunWorkerCommand('transcript', ['watch']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(bunPath, [transcriptWatcherPath, 'watch'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: marketplaceDirectory(),
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(pc.red(`Failed to start transcript watcher: ${error.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (exitCode) => {
|
||||||
|
process.exit(exitCode ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Uninstall command for `npx claude-mem uninstall`.
|
||||||
|
*
|
||||||
|
* Removes the plugin from the marketplace directory, cache, plugin
|
||||||
|
* registrations, and Claude settings. Optionally cleans up IDE-specific
|
||||||
|
* configurations.
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import * as p from '@clack/prompts';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
import { existsSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import {
|
||||||
|
claudeSettingsPath,
|
||||||
|
installedPluginsPath,
|
||||||
|
isPluginInstalled,
|
||||||
|
knownMarketplacesPath,
|
||||||
|
marketplaceDirectory,
|
||||||
|
pluginsDirectory,
|
||||||
|
writeJsonFileAtomic,
|
||||||
|
} from '../utils/paths.js';
|
||||||
|
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cleanup helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function removeMarketplaceDirectory(): boolean {
|
||||||
|
const marketplaceDir = marketplaceDirectory();
|
||||||
|
if (existsSync(marketplaceDir)) {
|
||||||
|
rmSync(marketplaceDir, { recursive: true, force: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCacheDirectory(): boolean {
|
||||||
|
const cacheDirectory = join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem');
|
||||||
|
if (existsSync(cacheDirectory)) {
|
||||||
|
rmSync(cacheDirectory, { recursive: true, force: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromKnownMarketplaces(): void {
|
||||||
|
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
|
||||||
|
if (knownMarketplaces['thedotmack']) {
|
||||||
|
delete knownMarketplaces['thedotmack'];
|
||||||
|
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromInstalledPlugins(): void {
|
||||||
|
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
|
||||||
|
if (installedPlugins.plugins?.['claude-mem@thedotmack']) {
|
||||||
|
delete installedPlugins.plugins['claude-mem@thedotmack'];
|
||||||
|
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromClaudeSettings(): void {
|
||||||
|
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
|
||||||
|
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
|
||||||
|
delete settings.enabledPlugins['claude-mem@thedotmack'];
|
||||||
|
writeJsonFileAtomic(claudeSettingsPath(), settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runUninstallCommand(): Promise<void> {
|
||||||
|
p.intro(pc.bgRed(pc.white(' claude-mem uninstall ')));
|
||||||
|
|
||||||
|
if (!isPluginInstalled()) {
|
||||||
|
p.log.warn('claude-mem does not appear to be installed.');
|
||||||
|
|
||||||
|
// Still offer to clean up partial state
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
const shouldCleanup = await p.confirm({
|
||||||
|
message: 'Clean up any remaining registration data anyway?',
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(shouldCleanup) || !shouldCleanup) {
|
||||||
|
p.outro('Nothing to do.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.outro('Nothing to do.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (process.stdin.isTTY) {
|
||||||
|
const shouldContinue = await p.confirm({
|
||||||
|
message: 'Are you sure you want to uninstall claude-mem?',
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(shouldContinue) || !shouldContinue) {
|
||||||
|
p.cancel('Uninstall cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the worker and wait for it to exit before deleting files
|
||||||
|
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${workerPort}/api/admin/shutdown`, {
|
||||||
|
method: 'POST',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
// Poll health endpoint until worker is gone (max 10s)
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${workerPort}/api/health`, {
|
||||||
|
signal: AbortSignal.timeout(1000),
|
||||||
|
});
|
||||||
|
// Still alive — keep waiting
|
||||||
|
} catch {
|
||||||
|
break; // Connection refused = worker is gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.log.info('Worker service stopped.');
|
||||||
|
} catch {
|
||||||
|
// Worker may not be running — that is fine
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.tasks([
|
||||||
|
{
|
||||||
|
title: 'Removing marketplace directory',
|
||||||
|
task: async () => {
|
||||||
|
const removed = removeMarketplaceDirectory();
|
||||||
|
return removed
|
||||||
|
? `Marketplace directory removed ${pc.green('OK')}`
|
||||||
|
: `Marketplace directory not found ${pc.dim('skipped')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing cache directory',
|
||||||
|
task: async () => {
|
||||||
|
const removed = removeCacheDirectory();
|
||||||
|
return removed
|
||||||
|
? `Cache directory removed ${pc.green('OK')}`
|
||||||
|
: `Cache directory not found ${pc.dim('skipped')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing marketplace registration',
|
||||||
|
task: async () => {
|
||||||
|
removeFromKnownMarketplaces();
|
||||||
|
return `Marketplace registration removed ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing plugin registration',
|
||||||
|
task: async () => {
|
||||||
|
removeFromInstalledPlugins();
|
||||||
|
return `Plugin registration removed ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing from Claude settings',
|
||||||
|
task: async () => {
|
||||||
|
removeFromClaudeSettings();
|
||||||
|
return `Claude settings updated ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Remove IDE-specific hooks and config (best-effort, each is independent)
|
||||||
|
const ideCleanups: Array<{ label: string; fn: () => Promise<number> | number }> = [
|
||||||
|
{ label: 'Gemini CLI hooks', fn: async () => {
|
||||||
|
const { uninstallGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
|
||||||
|
return uninstallGeminiCliHooks();
|
||||||
|
}},
|
||||||
|
{ label: 'Windsurf hooks', fn: async () => {
|
||||||
|
const { uninstallWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
|
||||||
|
return uninstallWindsurfHooks();
|
||||||
|
}},
|
||||||
|
{ label: 'OpenCode plugin', fn: async () => {
|
||||||
|
const { uninstallOpenCodePlugin } = await import('../../services/integrations/OpenCodeInstaller.js');
|
||||||
|
return uninstallOpenCodePlugin();
|
||||||
|
}},
|
||||||
|
{ label: 'OpenClaw plugin', fn: async () => {
|
||||||
|
const { uninstallOpenClawPlugin } = await import('../../services/integrations/OpenClawInstaller.js');
|
||||||
|
return uninstallOpenClawPlugin();
|
||||||
|
}},
|
||||||
|
{ label: 'Codex CLI', fn: async () => {
|
||||||
|
const { uninstallCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
|
||||||
|
return uninstallCodexCli();
|
||||||
|
}},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { label, fn } of ideCleanups) {
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
if (result === 0) {
|
||||||
|
p.log.info(`${label}: removed.`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// IDE not configured or uninstaller errored — skip silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.note(
|
||||||
|
[
|
||||||
|
`Your data directory at ${pc.cyan('~/.claude-mem')} was preserved.`,
|
||||||
|
'To remove it manually: rm -rf ~/.claude-mem',
|
||||||
|
].join('\n'),
|
||||||
|
'Note',
|
||||||
|
);
|
||||||
|
|
||||||
|
p.outro(pc.green('claude-mem has been uninstalled.'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* NPX CLI entry point for claude-mem.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx claude-mem → interactive install
|
||||||
|
* npx claude-mem install → interactive install
|
||||||
|
* npx claude-mem install --ide <id> → direct IDE setup
|
||||||
|
* npx claude-mem update → update to latest version
|
||||||
|
* npx claude-mem uninstall → remove plugin and IDE configs
|
||||||
|
* npx claude-mem version → print version
|
||||||
|
* npx claude-mem start → start worker service
|
||||||
|
* npx claude-mem stop → stop worker service
|
||||||
|
* npx claude-mem restart → restart worker service
|
||||||
|
* npx claude-mem status → show worker status
|
||||||
|
* npx claude-mem search <query> → search observations
|
||||||
|
* npx claude-mem transcript watch → start transcript watcher
|
||||||
|
*
|
||||||
|
* This file is pure Node.js — Bun is NOT required for install commands.
|
||||||
|
* Runtime commands (`start`, `stop`, etc.) delegate to Bun via the installed plugin.
|
||||||
|
*/
|
||||||
|
import pc from 'picocolors';
|
||||||
|
import { readPluginVersion } from './utils/paths.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Argument parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0]?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Help text
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
const version = readPluginVersion();
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
${pc.bold('claude-mem')} v${version} — persistent memory for AI coding assistants
|
||||||
|
|
||||||
|
${pc.bold('Install Commands')} (no Bun required):
|
||||||
|
${pc.cyan('npx claude-mem')} Interactive install
|
||||||
|
${pc.cyan('npx claude-mem install')} Interactive install
|
||||||
|
${pc.cyan('npx claude-mem install --ide <id>')} Install for specific IDE
|
||||||
|
${pc.cyan('npx claude-mem update')} Update to latest version
|
||||||
|
${pc.cyan('npx claude-mem uninstall')} Remove plugin and configs
|
||||||
|
${pc.cyan('npx claude-mem version')} Print version
|
||||||
|
|
||||||
|
${pc.bold('Runtime Commands')} (requires Bun, delegates to installed plugin):
|
||||||
|
${pc.cyan('npx claude-mem start')} Start worker service
|
||||||
|
${pc.cyan('npx claude-mem stop')} Stop worker service
|
||||||
|
${pc.cyan('npx claude-mem restart')} Restart worker service
|
||||||
|
${pc.cyan('npx claude-mem status')} Show worker status
|
||||||
|
${pc.cyan('npx claude-mem search <query>')} Search observations
|
||||||
|
${pc.cyan('npx claude-mem transcript watch')} Start transcript watcher
|
||||||
|
|
||||||
|
${pc.bold('IDE Identifiers')}:
|
||||||
|
claude-code, cursor, gemini-cli, opencode, openclaw,
|
||||||
|
windsurf, codex-cli, copilot-cli, antigravity, goose,
|
||||||
|
crush, roo-code, warp
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
switch (command) {
|
||||||
|
// -- No command: default to install ------------------------------------
|
||||||
|
case '': {
|
||||||
|
const { runInstallCommand } = await import('./commands/install.js');
|
||||||
|
await runInstallCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Install -----------------------------------------------------------
|
||||||
|
case 'install': {
|
||||||
|
const ideIndex = args.indexOf('--ide');
|
||||||
|
const ideValue = ideIndex !== -1 ? args[ideIndex + 1] : undefined;
|
||||||
|
|
||||||
|
const { runInstallCommand } = await import('./commands/install.js');
|
||||||
|
await runInstallCommand({ ide: ideValue });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Update (alias for install — overwrite with latest) ----------------
|
||||||
|
case 'update':
|
||||||
|
case 'upgrade': {
|
||||||
|
const { runInstallCommand } = await import('./commands/install.js');
|
||||||
|
await runInstallCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Uninstall ---------------------------------------------------------
|
||||||
|
case 'uninstall':
|
||||||
|
case 'remove': {
|
||||||
|
const { runUninstallCommand } = await import('./commands/uninstall.js');
|
||||||
|
await runUninstallCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Version -----------------------------------------------------------
|
||||||
|
case 'version':
|
||||||
|
case '--version':
|
||||||
|
case '-v': {
|
||||||
|
console.log(readPluginVersion());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Help --------------------------------------------------------------
|
||||||
|
case 'help':
|
||||||
|
case '--help':
|
||||||
|
case '-h': {
|
||||||
|
printHelp();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Runtime: start / stop / restart / status --------------------------
|
||||||
|
case 'start': {
|
||||||
|
const { runStartCommand } = await import('./commands/runtime.js');
|
||||||
|
runStartCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'stop': {
|
||||||
|
const { runStopCommand } = await import('./commands/runtime.js');
|
||||||
|
runStopCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'restart': {
|
||||||
|
const { runRestartCommand } = await import('./commands/runtime.js');
|
||||||
|
runRestartCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'status': {
|
||||||
|
const { runStatusCommand } = await import('./commands/runtime.js');
|
||||||
|
runStatusCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Search ------------------------------------------------------------
|
||||||
|
case 'search': {
|
||||||
|
const { runSearchCommand } = await import('./commands/runtime.js');
|
||||||
|
await runSearchCommand(args.slice(1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Transcript --------------------------------------------------------
|
||||||
|
case 'transcript': {
|
||||||
|
const subCommand = args[1]?.toLowerCase();
|
||||||
|
if (subCommand === 'watch') {
|
||||||
|
const { runTranscriptWatchCommand } = await import('./commands/runtime.js');
|
||||||
|
runTranscriptWatchCommand();
|
||||||
|
} else {
|
||||||
|
console.error(pc.red(`Unknown transcript subcommand: ${subCommand ?? '(none)'}`));
|
||||||
|
console.error(`Usage: npx claude-mem transcript watch`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Unknown -----------------------------------------------------------
|
||||||
|
default: {
|
||||||
|
console.error(pc.red(`Unknown command: ${command}`));
|
||||||
|
console.error(`Run ${pc.bold('npx claude-mem --help')} for usage information.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(pc.red('Fatal error:'), error.message || error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Bun binary resolution utility.
|
||||||
|
*
|
||||||
|
* Extracted from `plugin/scripts/bun-runner.js` so that the NPX CLI
|
||||||
|
* can locate Bun without duplicating the search logic.
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { IS_WINDOWS } from './paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Well-known locations where Bun might be installed, beyond PATH.
|
||||||
|
* Order matches the search priority in bun-runner.js and smart-install.js.
|
||||||
|
*/
|
||||||
|
function bunCandidatePaths(): string[] {
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
return [
|
||||||
|
join(homedir(), '.bun', 'bin', 'bun.exe'),
|
||||||
|
join(process.env.USERPROFILE || homedir(), '.bun', 'bin', 'bun.exe'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
join(homedir(), '.bun', 'bin', 'bun'),
|
||||||
|
'/usr/local/bin/bun',
|
||||||
|
'/opt/homebrew/bin/bun',
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin/bun',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to locate the Bun executable.
|
||||||
|
*
|
||||||
|
* 1. Check PATH via `which` / `where`.
|
||||||
|
* 2. Probe well-known installation directories.
|
||||||
|
*
|
||||||
|
* Returns the absolute path to the binary, `'bun'` if it is in PATH,
|
||||||
|
* or `null` if Bun cannot be found.
|
||||||
|
*/
|
||||||
|
export function resolveBunBinaryPath(): string | null {
|
||||||
|
// Try PATH first
|
||||||
|
const whichCommand = IS_WINDOWS ? 'where' : 'which';
|
||||||
|
const pathCheck = spawnSync(whichCommand, ['bun'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: IS_WINDOWS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pathCheck.status === 0 && pathCheck.stdout.trim()) {
|
||||||
|
return 'bun'; // Available in PATH — use short name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe known install locations
|
||||||
|
for (const candidatePath of bunCandidatePaths()) {
|
||||||
|
if (existsSync(candidatePath)) {
|
||||||
|
return candidatePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the installed Bun version string (e.g. `"1.2.3"`), or `null`
|
||||||
|
* if Bun is not available.
|
||||||
|
*/
|
||||||
|
export function getBunVersionString(): string | null {
|
||||||
|
const bunPath = resolveBunBinaryPath();
|
||||||
|
if (!bunPath) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(bunPath, ['--version'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: IS_WINDOWS,
|
||||||
|
});
|
||||||
|
return result.status === 0 ? result.stdout.trim() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Shared path utilities for the NPX CLI.
|
||||||
|
*
|
||||||
|
* All platform-specific path logic is centralized here so that every command
|
||||||
|
* resolves directories in exactly the same way, regardless of OS.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Platform detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const IS_WINDOWS = process.platform === 'win32';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Root of the Claude Code config directory. */
|
||||||
|
export function claudeConfigDirectory(): string {
|
||||||
|
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marketplace install directory for thedotmack. */
|
||||||
|
export function marketplaceDirectory(): string {
|
||||||
|
return join(claudeConfigDirectory(), 'plugins', 'marketplaces', 'thedotmack');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level plugins directory. */
|
||||||
|
export function pluginsDirectory(): string {
|
||||||
|
return join(claudeConfigDirectory(), 'plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path to `known_marketplaces.json`. */
|
||||||
|
export function knownMarketplacesPath(): string {
|
||||||
|
return join(pluginsDirectory(), 'known_marketplaces.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path to `installed_plugins.json`. */
|
||||||
|
export function installedPluginsPath(): string {
|
||||||
|
return join(pluginsDirectory(), 'installed_plugins.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path to `~/.claude/settings.json`. */
|
||||||
|
export function claudeSettingsPath(): string {
|
||||||
|
return join(claudeConfigDirectory(), 'settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plugin cache directory for a specific version. */
|
||||||
|
export function pluginCacheDirectory(version: string): string {
|
||||||
|
return join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem', version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** claude-mem data directory (default `~/.claude-mem`). */
|
||||||
|
export function claudeMemDataDirectory(): string {
|
||||||
|
return join(homedir(), '.claude-mem');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NPM package root (where the NPX package lives on disk)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the root of the installed npm package.
|
||||||
|
*
|
||||||
|
* After bundling, the CLI entry point lives at `<pkg>/dist/npx-cli/index.js`.
|
||||||
|
* Walking up 2 levels from `import.meta.url` reaches the package root
|
||||||
|
* where `plugin/` and `package.json` can be found.
|
||||||
|
*/
|
||||||
|
export function npmPackageRootDirectory(): string {
|
||||||
|
const currentFilePath = fileURLToPath(import.meta.url);
|
||||||
|
// <pkg>/dist/npx-cli/index.js -> up 2 levels -> <pkg>
|
||||||
|
const root = join(dirname(currentFilePath), '..', '..');
|
||||||
|
if (!existsSync(join(root, 'package.json'))) {
|
||||||
|
throw new Error(
|
||||||
|
`npmPackageRootDirectory: expected package.json at ${root}. ` +
|
||||||
|
`Bundle structure may have changed — update the path walk.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the `plugin/` directory bundled inside the npm package.
|
||||||
|
*/
|
||||||
|
export function npmPackagePluginDirectory(): string {
|
||||||
|
return join(npmPackageRootDirectory(), 'plugin');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Version helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current plugin version from the npm package's
|
||||||
|
* `plugin/.claude-plugin/plugin.json` (preferred) or from `package.json`.
|
||||||
|
*/
|
||||||
|
export function readPluginVersion(): string {
|
||||||
|
// Try plugin.json first (authoritative for plugin version)
|
||||||
|
const pluginJsonPath = join(npmPackagePluginDirectory(), '.claude-plugin', 'plugin.json');
|
||||||
|
if (existsSync(pluginJsonPath)) {
|
||||||
|
try {
|
||||||
|
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
|
||||||
|
if (pluginJson.version) return pluginJson.version;
|
||||||
|
} catch {
|
||||||
|
// Fall through to package.json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to package.json at package root
|
||||||
|
const packageJsonPath = join(npmPackageRootDirectory(), 'package.json');
|
||||||
|
if (existsSync(packageJsonPath)) {
|
||||||
|
try {
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
if (packageJson.version) return packageJson.version;
|
||||||
|
} catch {
|
||||||
|
// Unable to read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Installation detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns true if the plugin appears to be installed in the marketplace dir. */
|
||||||
|
export function isPluginInstalled(): boolean {
|
||||||
|
const marketplaceDir = marketplaceDirectory();
|
||||||
|
return existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON file helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ensureDirectoryExists(directoryPath: string): void {
|
||||||
|
if (!existsSync(directoryPath)) {
|
||||||
|
mkdirSync(directoryPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `readJsonSafe` from `../../utils/json-utils.js` instead.
|
||||||
|
* Kept as re-export for backward compatibility.
|
||||||
|
*/
|
||||||
|
export { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
|
||||||
|
export function writeJsonFileAtomic(filepath: string, data: any): void {
|
||||||
|
ensureDirectoryExists(dirname(filepath));
|
||||||
|
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||||
|
}
|
||||||
+1
-1
@@ -75,7 +75,7 @@ export function parseObservations(text: string, correlationId?: string): ParsedO
|
|||||||
const cleanedConcepts = concepts.filter(c => c !== finalType);
|
const cleanedConcepts = concepts.filter(c => c !== finalType);
|
||||||
|
|
||||||
if (cleanedConcepts.length !== concepts.length) {
|
if (cleanedConcepts.length !== concepts.length) {
|
||||||
logger.error('PARSER', 'Removed observation type from concepts array', {
|
logger.debug('PARSER', 'Removed observation type from concepts array', {
|
||||||
correlationId,
|
correlationId,
|
||||||
type: finalType,
|
type: finalType,
|
||||||
originalConcepts: concepts,
|
originalConcepts: concepts,
|
||||||
|
|||||||
+6
-2
@@ -116,7 +116,11 @@ export function buildObservationPrompt(obs: Observation): string {
|
|||||||
<occurred_at>${new Date(obs.created_at_epoch).toISOString()}</occurred_at>${obs.cwd ? `\n <working_directory>${obs.cwd}</working_directory>` : ''}
|
<occurred_at>${new Date(obs.created_at_epoch).toISOString()}</occurred_at>${obs.cwd ? `\n <working_directory>${obs.cwd}</working_directory>` : ''}
|
||||||
<parameters>${JSON.stringify(toolInput, null, 2)}</parameters>
|
<parameters>${JSON.stringify(toolInput, null, 2)}</parameters>
|
||||||
<outcome>${JSON.stringify(toolOutput, null, 2)}</outcome>
|
<outcome>${JSON.stringify(toolOutput, null, 2)}</outcome>
|
||||||
</observed_from_primary_session>`;
|
</observed_from_primary_session>
|
||||||
|
|
||||||
|
Return either one or more <observation>...</observation> blocks, or an empty response if this tool use should be skipped.
|
||||||
|
Concrete debugging findings from logs, queue state, database rows, session routing, or code-path inspection count as durable discoveries and should be recorded.
|
||||||
|
Never reply with prose such as "Skipping", "No substantive tool executions", or any explanation outside XML. Non-XML text is discarded.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,4 +239,4 @@ ${mode.prompts.format_examples}
|
|||||||
${mode.prompts.footer}
|
${mode.prompts.footer}
|
||||||
|
|
||||||
${mode.prompts.header_memory_continued}`;
|
${mode.prompts.header_memory_continued}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import net from 'net';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
import { MARKETPLACE_ROOT } from '../../shared/paths.js';
|
import { MARKETPLACE_ROOT } from '../../shared/paths.js';
|
||||||
@@ -35,17 +36,43 @@ async function httpRequestToWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a port is in use by querying the health endpoint
|
* Check if a port is in use by attempting an atomic socket bind.
|
||||||
|
* More reliable than HTTP health check for daemon spawn guards —
|
||||||
|
* prevents TOCTOU race where two daemons both see "port free" via
|
||||||
|
* HTTP and then both try to listen() (upstream bug workaround).
|
||||||
|
*
|
||||||
|
* Falls back to HTTP health check on Windows where socket bind
|
||||||
|
* behavior differs.
|
||||||
*/
|
*/
|
||||||
export async function isPortInUse(port: number): Promise<boolean> {
|
export async function isPortInUse(port: number): Promise<boolean> {
|
||||||
try {
|
if (process.platform === 'win32') {
|
||||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
// APPROVED OVERRIDE: Windows keeps HTTP health check because socket bind
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
// semantics differ (SO_REUSEADDR defaults, firewall prompts). The TOCTOU
|
||||||
return response.ok;
|
// race remains on Windows but is an accepted limitation — the atomic
|
||||||
} catch (error) {
|
// socket approach would cause false positives or UAC popups.
|
||||||
// [ANTI-PATTERN IGNORED]: Health check polls every 500ms, logging would flood
|
try {
|
||||||
return false;
|
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unix: atomic socket bind check — no TOCTOU race
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close(() => resolve(false));
|
||||||
|
});
|
||||||
|
server.listen(port, '127.0.0.1');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* CodexCliInstaller - Codex CLI integration for claude-mem
|
||||||
|
*
|
||||||
|
* Uses transcript-only watching (no notify hook). The watcher infrastructure
|
||||||
|
* already exists in src/services/transcripts/. This installer:
|
||||||
|
*
|
||||||
|
* 1. Writes/merges transcript-watch config to ~/.claude-mem/transcript-watch.json
|
||||||
|
* 2. Sets up watch for ~/.codex/sessions/**\/*.jsonl using existing watcher
|
||||||
|
* 3. Injects context via ~/.codex/AGENTS.md (Codex reads this natively)
|
||||||
|
*
|
||||||
|
* Anti-patterns:
|
||||||
|
* - Does NOT add notify hooks -- transcript watching is sufficient
|
||||||
|
* - Does NOT modify existing transcript watcher infrastructure
|
||||||
|
* - Does NOT overwrite existing transcript-watch.json -- merges only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { replaceTaggedContent } from '../../utils/claude-md-utils.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_CONFIG_PATH,
|
||||||
|
DEFAULT_STATE_PATH,
|
||||||
|
SAMPLE_CONFIG,
|
||||||
|
} from '../transcripts/config.js';
|
||||||
|
import type { TranscriptWatchConfig, WatchTarget } from '../transcripts/types.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CODEX_DIR = path.join(homedir(), '.codex');
|
||||||
|
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
|
||||||
|
const CLAUDE_MEM_DIR = path.join(homedir(), '.claude-mem');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watch name used to identify the Codex CLI entry in transcript-watch.json.
|
||||||
|
* Must match the name in SAMPLE_CONFIG for merging to work correctly.
|
||||||
|
*/
|
||||||
|
const CODEX_WATCH_NAME = 'codex';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transcript Watch Config Merging
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing transcript-watch.json, or return an empty config scaffold.
|
||||||
|
* Never throws -- returns a valid empty config on any parse error.
|
||||||
|
*/
|
||||||
|
function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig {
|
||||||
|
const configPath = DEFAULT_CONFIG_PATH;
|
||||||
|
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(configPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
|
||||||
|
|
||||||
|
// Ensure required fields exist
|
||||||
|
if (!parsed.version) parsed.version = 1;
|
||||||
|
if (!parsed.watches) parsed.watches = [];
|
||||||
|
if (!parsed.schemas) parsed.schemas = {};
|
||||||
|
if (!parsed.stateFile) parsed.stateFile = DEFAULT_STATE_PATH;
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error('CODEX', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error);
|
||||||
|
|
||||||
|
// Back up corrupt file
|
||||||
|
const backupPath = `${configPath}.backup.${Date.now()}`;
|
||||||
|
writeFileSync(backupPath, readFileSync(configPath));
|
||||||
|
console.warn(` Backed up corrupt transcript-watch.json to ${backupPath}`);
|
||||||
|
|
||||||
|
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Codex watch configuration into existing transcript-watch.json.
|
||||||
|
*
|
||||||
|
* - If a watch with name 'codex' already exists, it is replaced in-place.
|
||||||
|
* - If the 'codex' schema already exists, it is replaced in-place.
|
||||||
|
* - All other watches and schemas are preserved untouched.
|
||||||
|
*/
|
||||||
|
function mergeCodexWatchConfig(existingConfig: TranscriptWatchConfig): TranscriptWatchConfig {
|
||||||
|
const merged = { ...existingConfig };
|
||||||
|
|
||||||
|
// Merge schemas: add/replace the codex schema
|
||||||
|
merged.schemas = { ...merged.schemas };
|
||||||
|
const codexSchema = SAMPLE_CONFIG.schemas?.[CODEX_WATCH_NAME];
|
||||||
|
if (codexSchema) {
|
||||||
|
merged.schemas[CODEX_WATCH_NAME] = codexSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge watches: add/replace the codex watch entry
|
||||||
|
const codexWatchFromSample = SAMPLE_CONFIG.watches.find(
|
||||||
|
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (codexWatchFromSample) {
|
||||||
|
const existingWatchIndex = merged.watches.findIndex(
|
||||||
|
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingWatchIndex !== -1) {
|
||||||
|
// Replace existing codex watch in-place
|
||||||
|
merged.watches[existingWatchIndex] = codexWatchFromSample;
|
||||||
|
} else {
|
||||||
|
// Append new codex watch
|
||||||
|
merged.watches.push(codexWatchFromSample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the merged transcript-watch.json config atomically.
|
||||||
|
*/
|
||||||
|
function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void {
|
||||||
|
mkdirSync(CLAUDE_MEM_DIR, { recursive: true });
|
||||||
|
writeFileSync(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context Injection (AGENTS.md)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject claude-mem context section into ~/.codex/AGENTS.md.
|
||||||
|
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md and GEMINI.md.
|
||||||
|
* Preserves any existing user content outside the tags.
|
||||||
|
*/
|
||||||
|
function injectCodexAgentsMdContext(): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(CODEX_DIR, { recursive: true });
|
||||||
|
|
||||||
|
let existingContent = '';
|
||||||
|
if (existsSync(CODEX_AGENTS_MD_PATH)) {
|
||||||
|
existingContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial placeholder content -- will be populated after first session
|
||||||
|
const contextContent = [
|
||||||
|
'# Recent Activity',
|
||||||
|
'',
|
||||||
|
'<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->',
|
||||||
|
'',
|
||||||
|
'*No context yet. Complete your first session and context will appear here.*',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const finalContent = replaceTaggedContent(existingContent, contextContent);
|
||||||
|
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent);
|
||||||
|
console.log(` Injected context placeholder into ${CODEX_AGENTS_MD_PATH}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Non-fatal -- transcript watching still works without context injection
|
||||||
|
logger.warn('CODEX', 'Failed to inject AGENTS.md context', { error: (error as Error).message });
|
||||||
|
console.warn(` Warning: Could not inject context into AGENTS.md: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove claude-mem context section from AGENTS.md.
|
||||||
|
* Preserves user content outside the <claude-mem-context> tags.
|
||||||
|
*/
|
||||||
|
function removeCodexAgentsMdContext(): void {
|
||||||
|
try {
|
||||||
|
if (!existsSync(CODEX_AGENTS_MD_PATH)) return;
|
||||||
|
|
||||||
|
const content = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
|
||||||
|
const startTag = '<claude-mem-context>';
|
||||||
|
const endTag = '</claude-mem-context>';
|
||||||
|
|
||||||
|
const startIdx = content.indexOf(startTag);
|
||||||
|
const endIdx = content.indexOf(endTag);
|
||||||
|
|
||||||
|
if (startIdx === -1 || endIdx === -1) return;
|
||||||
|
|
||||||
|
// Remove the tagged section and any surrounding blank lines
|
||||||
|
const before = content.substring(0, startIdx).replace(/\n+$/, '');
|
||||||
|
const after = content.substring(endIdx + endTag.length).replace(/^\n+/, '');
|
||||||
|
const finalContent = (before + (after ? '\n\n' + after : '')).trim();
|
||||||
|
|
||||||
|
if (finalContent) {
|
||||||
|
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent + '\n');
|
||||||
|
} else {
|
||||||
|
// File would be empty -- leave it empty rather than deleting
|
||||||
|
// (user may have other tooling that expects it to exist)
|
||||||
|
writeFileSync(CODEX_AGENTS_MD_PATH, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Removed context section from ${CODEX_AGENTS_MD_PATH}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('CODEX', 'Failed to clean AGENTS.md context', { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API: Install
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Codex CLI integration for claude-mem.
|
||||||
|
*
|
||||||
|
* 1. Merges Codex transcript-watch config into ~/.claude-mem/transcript-watch.json
|
||||||
|
* 2. Injects context placeholder into ~/.codex/AGENTS.md
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installCodexCli(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem for Codex CLI (transcript watching)...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Merge transcript-watch config
|
||||||
|
const existingConfig = loadExistingTranscriptWatchConfig();
|
||||||
|
const mergedConfig = mergeCodexWatchConfig(existingConfig);
|
||||||
|
writeTranscriptWatchConfig(mergedConfig);
|
||||||
|
console.log(` Updated ${DEFAULT_CONFIG_PATH}`);
|
||||||
|
console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`);
|
||||||
|
console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`);
|
||||||
|
|
||||||
|
// Step 2: Inject context into AGENTS.md
|
||||||
|
injectCodexAgentsMdContext();
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Transcript watch config: ${DEFAULT_CONFIG_PATH}
|
||||||
|
Context file: ${CODEX_AGENTS_MD_PATH}
|
||||||
|
|
||||||
|
How it works:
|
||||||
|
- claude-mem watches Codex session JSONL files for new activity
|
||||||
|
- No hooks needed -- transcript watching is fully automatic
|
||||||
|
- Context from past sessions is injected via ${CODEX_AGENTS_MD_PATH}
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: npx claude-mem start
|
||||||
|
2. Use Codex CLI as usual -- memory capture is automatic!
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API: Uninstall
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove Codex CLI integration from claude-mem.
|
||||||
|
*
|
||||||
|
* 1. Removes the codex watch and schema from transcript-watch.json (preserves others)
|
||||||
|
* 2. Removes context section from AGENTS.md (preserves user content)
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function uninstallCodexCli(): number {
|
||||||
|
console.log('\nUninstalling Claude-Mem Codex CLI integration...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Remove codex watch from transcript-watch.json
|
||||||
|
if (existsSync(DEFAULT_CONFIG_PATH)) {
|
||||||
|
const config = loadExistingTranscriptWatchConfig();
|
||||||
|
|
||||||
|
// Remove codex watch
|
||||||
|
config.watches = config.watches.filter(
|
||||||
|
(w: WatchTarget) => w.name !== CODEX_WATCH_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove codex schema
|
||||||
|
if (config.schemas) {
|
||||||
|
delete config.schemas[CODEX_WATCH_NAME];
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTranscriptWatchConfig(config);
|
||||||
|
console.log(` Removed codex watch from ${DEFAULT_CONFIG_PATH}`);
|
||||||
|
} else {
|
||||||
|
console.log(' No transcript-watch.json found -- nothing to remove.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Remove context section from AGENTS.md
|
||||||
|
removeCodexAgentsMdContext();
|
||||||
|
|
||||||
|
console.log('\nUninstallation complete!');
|
||||||
|
console.log('Restart claude-mem worker to apply changes.\n');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nUninstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API: Status Check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Codex CLI integration status.
|
||||||
|
*
|
||||||
|
* @returns 0 always (informational)
|
||||||
|
*/
|
||||||
|
export function checkCodexCliStatus(): number {
|
||||||
|
console.log('\nClaude-Mem Codex CLI Integration Status\n');
|
||||||
|
|
||||||
|
// Check transcript-watch.json
|
||||||
|
if (!existsSync(DEFAULT_CONFIG_PATH)) {
|
||||||
|
console.log('Status: Not installed');
|
||||||
|
console.log(` No transcript watch config at ${DEFAULT_CONFIG_PATH}`);
|
||||||
|
console.log('\nRun: npx claude-mem install --ide codex-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = loadExistingTranscriptWatchConfig();
|
||||||
|
const codexWatch = config.watches.find(
|
||||||
|
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
|
||||||
|
);
|
||||||
|
const codexSchema = config.schemas?.[CODEX_WATCH_NAME];
|
||||||
|
|
||||||
|
if (!codexWatch) {
|
||||||
|
console.log('Status: Not installed');
|
||||||
|
console.log(' transcript-watch.json exists but no codex watch configured.');
|
||||||
|
console.log('\nRun: npx claude-mem install --ide codex-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Status: Installed');
|
||||||
|
console.log(` Config: ${DEFAULT_CONFIG_PATH}`);
|
||||||
|
console.log(` Watch path: ${codexWatch.path}`);
|
||||||
|
console.log(` Schema: ${codexSchema ? `codex (v${codexSchema.version ?? '?'})` : 'missing'}`);
|
||||||
|
console.log(` Start at end: ${codexWatch.startAtEnd ?? false}`);
|
||||||
|
|
||||||
|
// Check context config
|
||||||
|
if (codexWatch.context) {
|
||||||
|
console.log(` Context mode: ${codexWatch.context.mode}`);
|
||||||
|
console.log(` Context path: ${codexWatch.context.path ?? 'default'}`);
|
||||||
|
console.log(` Context updates on: ${codexWatch.context.updateOn?.join(', ') ?? 'none'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check AGENTS.md
|
||||||
|
if (existsSync(CODEX_AGENTS_MD_PATH)) {
|
||||||
|
const mdContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
|
||||||
|
if (mdContent.includes('<claude-mem-context>')) {
|
||||||
|
console.log(` Context: Active (${CODEX_AGENTS_MD_PATH})`);
|
||||||
|
} else {
|
||||||
|
console.log(` Context: AGENTS.md exists but no context tags`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` Context: No AGENTS.md file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ~/.codex/sessions exists (indicates Codex has been used)
|
||||||
|
const sessionsDir = path.join(CODEX_DIR, 'sessions');
|
||||||
|
if (existsSync(sessionsDir)) {
|
||||||
|
console.log(` Sessions directory: exists`);
|
||||||
|
} else {
|
||||||
|
console.log(` Sessions directory: not yet created (use Codex CLI to generate sessions)`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log('Status: Unknown');
|
||||||
|
console.log(' Could not parse transcript-watch.json.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -133,9 +133,7 @@ export function findMcpServerPath(): string | null {
|
|||||||
const possiblePaths = [
|
const possiblePaths = [
|
||||||
// Marketplace install location
|
// Marketplace install location
|
||||||
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'mcp-server.cjs'),
|
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'mcp-server.cjs'),
|
||||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
// Development/source location
|
||||||
path.join(path.dirname(__filename), 'mcp-server.cjs'),
|
|
||||||
// Alternative dev location
|
|
||||||
path.join(process.cwd(), 'plugin', 'scripts', 'mcp-server.cjs'),
|
path.join(process.cwd(), 'plugin', 'scripts', 'mcp-server.cjs'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -155,9 +153,7 @@ export function findWorkerServicePath(): string | null {
|
|||||||
const possiblePaths = [
|
const possiblePaths = [
|
||||||
// Marketplace install location
|
// Marketplace install location
|
||||||
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs'),
|
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs'),
|
||||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
// Development/source location
|
||||||
path.join(path.dirname(__filename), 'worker-service.cjs'),
|
|
||||||
// Alternative dev location
|
|
||||||
path.join(process.cwd(), 'plugin', 'scripts', 'worker-service.cjs'),
|
path.join(process.cwd(), 'plugin', 'scripts', 'worker-service.cjs'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
/**
|
||||||
|
* GeminiCliHooksInstaller - Gemini CLI integration for claude-mem
|
||||||
|
*
|
||||||
|
* Installs hooks into ~/.gemini/settings.json using the unified CLI:
|
||||||
|
* bun worker-service.cjs hook gemini-cli <event>
|
||||||
|
*
|
||||||
|
* This routes through the hook-command.ts framework:
|
||||||
|
* readJsonFromStdin() → gemini-cli adapter → event handler → POST to worker
|
||||||
|
*
|
||||||
|
* Gemini CLI supports 11 lifecycle hooks; we register 8 that map to
|
||||||
|
* useful memory events. See src/cli/adapters/gemini-cli.ts for the
|
||||||
|
* adapter that normalizes Gemini's stdin JSON to NormalizedHookInput.
|
||||||
|
*
|
||||||
|
* Hook config format (verified against Gemini CLI source):
|
||||||
|
* {
|
||||||
|
* "hooks": {
|
||||||
|
* "AfterTool": [{
|
||||||
|
* "matcher": "*",
|
||||||
|
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000 }]
|
||||||
|
* }]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { findWorkerServicePath, findBunPath } from './CursorHooksInstaller.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** A single hook entry in a Gemini CLI hook group */
|
||||||
|
interface GeminiHookEntry {
|
||||||
|
name: string;
|
||||||
|
type: 'command';
|
||||||
|
command: string;
|
||||||
|
timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hook group — matcher selects which tools/events this applies to */
|
||||||
|
interface GeminiHookGroup {
|
||||||
|
matcher: string;
|
||||||
|
hooks: GeminiHookEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hooks section in ~/.gemini/settings.json */
|
||||||
|
interface GeminiHooksConfig {
|
||||||
|
[eventName: string]: GeminiHookGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full ~/.gemini/settings.json structure (partial — we only care about hooks) */
|
||||||
|
interface GeminiSettingsJson {
|
||||||
|
hooks?: GeminiHooksConfig;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const GEMINI_CONFIG_DIR = path.join(homedir(), '.gemini');
|
||||||
|
const GEMINI_SETTINGS_PATH = path.join(GEMINI_CONFIG_DIR, 'settings.json');
|
||||||
|
const GEMINI_MD_PATH = path.join(GEMINI_CONFIG_DIR, 'GEMINI.md');
|
||||||
|
|
||||||
|
const HOOK_NAME = 'claude-mem';
|
||||||
|
const HOOK_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from Gemini CLI hook events to internal claude-mem event types.
|
||||||
|
*
|
||||||
|
* These events are processed by hookCommand() in src/cli/hook-command.ts,
|
||||||
|
* which reads stdin via readJsonFromStdin(), normalizes through the
|
||||||
|
* gemini-cli adapter, and dispatches to the matching event handler.
|
||||||
|
*
|
||||||
|
* Events NOT mapped (too chatty for memory capture):
|
||||||
|
* BeforeModel, AfterModel, BeforeToolSelection
|
||||||
|
*/
|
||||||
|
const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = {
|
||||||
|
'SessionStart': 'context',
|
||||||
|
'BeforeAgent': 'user-message',
|
||||||
|
'AfterAgent': 'observation',
|
||||||
|
'BeforeTool': 'observation',
|
||||||
|
'AfterTool': 'observation',
|
||||||
|
'PreCompress': 'summarize',
|
||||||
|
'Notification': 'observation',
|
||||||
|
'SessionEnd': 'session-complete',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook Command Builder
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the hook command string for a given Gemini CLI event.
|
||||||
|
*
|
||||||
|
* The command invokes worker-service.cjs with the `hook` subcommand,
|
||||||
|
* which delegates to hookCommand('gemini-cli', event) — the same
|
||||||
|
* framework used by Claude Code and Cursor hooks.
|
||||||
|
*
|
||||||
|
* Pipeline: bun worker-service.cjs hook gemini-cli <event>
|
||||||
|
* → worker-service.ts parses args, ensures worker daemon is running
|
||||||
|
* → hookCommand('gemini-cli', '<event>')
|
||||||
|
* → readJsonFromStdin() reads Gemini's JSON payload
|
||||||
|
* → geminiCliAdapter.normalizeInput() → NormalizedHookInput
|
||||||
|
* → eventHandler.execute(input)
|
||||||
|
* → geminiCliAdapter.formatOutput(result)
|
||||||
|
* → JSON.stringify to stdout
|
||||||
|
*/
|
||||||
|
function buildHookCommand(
|
||||||
|
bunPath: string,
|
||||||
|
workerServicePath: string,
|
||||||
|
geminiEventName: string,
|
||||||
|
): string {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[geminiEventName];
|
||||||
|
if (!internalEvent) {
|
||||||
|
throw new Error(`Unknown Gemini CLI event: ${geminiEventName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-escape backslashes intentionally: this command string is embedded inside
|
||||||
|
// a JSON value, so `\\` in the source becomes `\` when the JSON is parsed by the
|
||||||
|
// IDE. Without double-escaping, Windows paths like C:\Users would lose their
|
||||||
|
// backslashes and break when the IDE deserializes the hook configuration.
|
||||||
|
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
|
||||||
|
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
|
||||||
|
|
||||||
|
return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${internalEvent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a hook group entry for a Gemini CLI event.
|
||||||
|
* Uses matcher "*" to match all tools/contexts for that event.
|
||||||
|
*/
|
||||||
|
function createHookGroup(hookCommand: string): GeminiHookGroup {
|
||||||
|
return {
|
||||||
|
matcher: '*',
|
||||||
|
hooks: [{
|
||||||
|
name: HOOK_NAME,
|
||||||
|
type: 'command',
|
||||||
|
command: hookCommand,
|
||||||
|
timeout: HOOK_TIMEOUT_MS,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Settings JSON Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read ~/.gemini/settings.json, returning empty object if missing.
|
||||||
|
* Throws on corrupt JSON to prevent silent data loss.
|
||||||
|
*/
|
||||||
|
function readGeminiSettings(): GeminiSettingsJson {
|
||||||
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(GEMINI_SETTINGS_PATH, 'utf-8');
|
||||||
|
try {
|
||||||
|
return JSON.parse(content) as GeminiSettingsJson;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Corrupt JSON in ${GEMINI_SETTINGS_PATH}, refusing to overwrite user settings`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write settings back to ~/.gemini/settings.json.
|
||||||
|
* Creates the directory if it doesn't exist.
|
||||||
|
*/
|
||||||
|
function writeGeminiSettings(settings: GeminiSettingsJson): void {
|
||||||
|
mkdirSync(GEMINI_CONFIG_DIR, { recursive: true });
|
||||||
|
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-merge claude-mem hooks into existing settings.
|
||||||
|
*
|
||||||
|
* For each event:
|
||||||
|
* - If the event already has a hook group with a claude-mem hook, update it
|
||||||
|
* - Otherwise, append a new hook group
|
||||||
|
*
|
||||||
|
* Preserves all non-claude-mem hooks and all non-hook settings.
|
||||||
|
*/
|
||||||
|
function mergeHooksIntoSettings(
|
||||||
|
existingSettings: GeminiSettingsJson,
|
||||||
|
newHooks: GeminiHooksConfig,
|
||||||
|
): GeminiSettingsJson {
|
||||||
|
const settings = { ...existingSettings };
|
||||||
|
if (!settings.hooks) {
|
||||||
|
settings.hooks = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [eventName, newGroups] of Object.entries(newHooks)) {
|
||||||
|
const existingGroups: GeminiHookGroup[] = settings.hooks[eventName] ?? [];
|
||||||
|
|
||||||
|
// For each new hook group, check if there's already a group
|
||||||
|
// containing a claude-mem hook — update it in place
|
||||||
|
for (const newGroup of newGroups) {
|
||||||
|
const existingGroupIndex = existingGroups.findIndex((group: GeminiHookGroup) =>
|
||||||
|
group.hooks.some((hook: GeminiHookEntry) => hook.name === HOOK_NAME)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingGroupIndex >= 0) {
|
||||||
|
// Update existing group: replace the claude-mem hook entry
|
||||||
|
const existingGroup: GeminiHookGroup = existingGroups[existingGroupIndex];
|
||||||
|
const hookIndex = existingGroup.hooks.findIndex((hook: GeminiHookEntry) => hook.name === HOOK_NAME);
|
||||||
|
if (hookIndex >= 0) {
|
||||||
|
existingGroup.hooks[hookIndex] = newGroup.hooks[0];
|
||||||
|
} else {
|
||||||
|
existingGroup.hooks.push(newGroup.hooks[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No existing claude-mem group — append
|
||||||
|
existingGroups.push(newGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.hooks[eventName] = existingGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GEMINI.md Context Injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append or update the claude-mem context section in ~/.gemini/GEMINI.md.
|
||||||
|
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md.
|
||||||
|
*/
|
||||||
|
function setupGeminiMdContextSection(): void {
|
||||||
|
const contextTag = '<claude-mem-context>';
|
||||||
|
const contextEndTag = '</claude-mem-context>';
|
||||||
|
const placeholder = `${contextTag}
|
||||||
|
# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
${contextEndTag}`;
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
|
content = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes(contextTag)) {
|
||||||
|
// Already has claude-mem section — leave it alone (may have real context)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the section
|
||||||
|
const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : content.length > 0 ? '\n' : '';
|
||||||
|
const newContent = content + separator + placeholder + '\n';
|
||||||
|
|
||||||
|
mkdirSync(GEMINI_CONFIG_DIR, { recursive: true });
|
||||||
|
writeFileSync(GEMINI_MD_PATH, newContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install claude-mem hooks into ~/.gemini/settings.json.
|
||||||
|
*
|
||||||
|
* Merges hooks non-destructively: existing settings and non-claude-mem
|
||||||
|
* hooks are preserved. Existing claude-mem hooks are updated in place.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installGeminiCliHooks(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem Gemini CLI hooks...\n');
|
||||||
|
|
||||||
|
// Find required paths
|
||||||
|
const workerServicePath = findWorkerServicePath();
|
||||||
|
if (!workerServicePath) {
|
||||||
|
console.error('Could not find worker-service.cjs');
|
||||||
|
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bunPath = findBunPath();
|
||||||
|
console.log(` Using Bun runtime: ${bunPath}`);
|
||||||
|
console.log(` Worker service: ${workerServicePath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build hook commands for all mapped events
|
||||||
|
const hooksConfig: GeminiHooksConfig = {};
|
||||||
|
for (const geminiEvent of Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT)) {
|
||||||
|
const command = buildHookCommand(bunPath, workerServicePath, geminiEvent);
|
||||||
|
hooksConfig[geminiEvent] = [createHookGroup(command)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing settings and merge
|
||||||
|
const existingSettings = readGeminiSettings();
|
||||||
|
const mergedSettings = mergeHooksIntoSettings(existingSettings, hooksConfig);
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
writeGeminiSettings(mergedSettings);
|
||||||
|
console.log(` Merged hooks into ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
|
||||||
|
// Setup GEMINI.md context injection
|
||||||
|
setupGeminiMdContextSection();
|
||||||
|
console.log(` Setup context injection in ${GEMINI_MD_PATH}`);
|
||||||
|
|
||||||
|
// List installed events
|
||||||
|
const eventNames = Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT);
|
||||||
|
console.log(` Registered ${eventNames.length} hook events:`);
|
||||||
|
for (const event of eventNames) {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event];
|
||||||
|
console.log(` ${event} → ${internalEvent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Hooks installed to: ${GEMINI_SETTINGS_PATH}
|
||||||
|
Using unified CLI: bun worker-service.cjs hook gemini-cli <event>
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: claude-mem start
|
||||||
|
2. Restart Gemini CLI to load the hooks
|
||||||
|
3. Memory will be captured automatically during sessions
|
||||||
|
|
||||||
|
Context Injection:
|
||||||
|
Context from past sessions is injected via ~/.gemini/GEMINI.md
|
||||||
|
and automatically included in Gemini CLI conversations.
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstall claude-mem hooks from ~/.gemini/settings.json.
|
||||||
|
*
|
||||||
|
* Removes only claude-mem hooks — other hooks and settings are preserved.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function uninstallGeminiCliHooks(): number {
|
||||||
|
console.log('\nUninstalling Claude-Mem Gemini CLI hooks...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
|
console.log(' No Gemini CLI settings found — nothing to uninstall.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = readGeminiSettings();
|
||||||
|
if (!settings.hooks) {
|
||||||
|
console.log(' No hooks found in Gemini CLI settings — nothing to uninstall.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
// Remove claude-mem hooks from within each group, preserving other hooks
|
||||||
|
for (const [eventName, groups] of Object.entries(settings.hooks)) {
|
||||||
|
const filteredGroups = groups
|
||||||
|
.map(group => {
|
||||||
|
const remainingHooks = group.hooks.filter(hook => hook.name !== HOOK_NAME);
|
||||||
|
removedCount += group.hooks.length - remainingHooks.length;
|
||||||
|
return { ...group, hooks: remainingHooks };
|
||||||
|
})
|
||||||
|
.filter(group => group.hooks.length > 0);
|
||||||
|
|
||||||
|
if (filteredGroups.length > 0) {
|
||||||
|
settings.hooks[eventName] = filteredGroups;
|
||||||
|
} else {
|
||||||
|
delete settings.hooks[eventName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty hooks object
|
||||||
|
if (Object.keys(settings.hooks).length === 0) {
|
||||||
|
delete settings.hooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeGeminiSettings(settings);
|
||||||
|
console.log(` Removed ${removedCount} claude-mem hook(s) from ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
|
||||||
|
// Remove claude-mem context section from GEMINI.md
|
||||||
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
|
let mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
|
const contextRegex = /\n?<claude-mem-context>[\s\S]*?<\/claude-mem-context>\n?/;
|
||||||
|
if (contextRegex.test(mdContent)) {
|
||||||
|
mdContent = mdContent.replace(contextRegex, '');
|
||||||
|
writeFileSync(GEMINI_MD_PATH, mdContent);
|
||||||
|
console.log(` Removed context section from ${GEMINI_MD_PATH}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nUninstallation complete!\n');
|
||||||
|
console.log('Restart Gemini CLI to apply changes.');
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nUninstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Gemini CLI hooks installation status.
|
||||||
|
*
|
||||||
|
* @returns 0 always (informational)
|
||||||
|
*/
|
||||||
|
export function checkGeminiCliHooksStatus(): number {
|
||||||
|
console.log('\nClaude-Mem Gemini CLI Hooks Status\n');
|
||||||
|
|
||||||
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
|
console.log('Gemini CLI settings: Not found');
|
||||||
|
console.log(` Expected at: ${GEMINI_SETTINGS_PATH}\n`);
|
||||||
|
console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: GeminiSettingsJson;
|
||||||
|
try {
|
||||||
|
settings = readGeminiSettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Gemini CLI settings: ${(error as Error).message}\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.hooks) {
|
||||||
|
console.log('Gemini CLI settings: Found, but no hooks configured\n');
|
||||||
|
console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for claude-mem hooks
|
||||||
|
const installedEvents: string[] = [];
|
||||||
|
for (const [eventName, groups] of Object.entries(settings.hooks)) {
|
||||||
|
const hasClaudeMem = groups.some(group =>
|
||||||
|
group.hooks.some(hook => hook.name === HOOK_NAME)
|
||||||
|
);
|
||||||
|
if (hasClaudeMem) {
|
||||||
|
installedEvents.push(eventName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installedEvents.length === 0) {
|
||||||
|
console.log('Gemini CLI settings: Found, but no claude-mem hooks\n');
|
||||||
|
console.log('Run: claude-mem install --ide gemini-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Settings: ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
console.log(`Mode: Unified CLI (bun worker-service.cjs hook gemini-cli)`);
|
||||||
|
console.log(`Events: ${installedEvents.length} of ${Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT).length} mapped`);
|
||||||
|
for (const event of installedEvents) {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event] ?? 'unknown';
|
||||||
|
console.log(` ${event} → ${internalEvent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check GEMINI.md context
|
||||||
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
|
const mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
|
if (mdContent.includes('<claude-mem-context>')) {
|
||||||
|
console.log(`Context: Active (${GEMINI_MD_PATH})`);
|
||||||
|
} else {
|
||||||
|
console.log('Context: GEMINI.md exists but missing claude-mem section');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Context: No GEMINI.md found');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle gemini-cli subcommand for hooks management.
|
||||||
|
*/
|
||||||
|
export async function handleGeminiCliCommand(subcommand: string, _args: string[]): Promise<number> {
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'install':
|
||||||
|
return installGeminiCliHooks();
|
||||||
|
|
||||||
|
case 'uninstall':
|
||||||
|
return uninstallGeminiCliHooks();
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
return checkGeminiCliHooksStatus();
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`
|
||||||
|
Claude-Mem Gemini CLI Integration
|
||||||
|
|
||||||
|
Usage: claude-mem gemini-cli <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Install hooks into ~/.gemini/settings.json
|
||||||
|
uninstall Remove claude-mem hooks (preserves other hooks)
|
||||||
|
status Check installation status
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
claude-mem gemini-cli install # Install hooks
|
||||||
|
claude-mem gemini-cli status # Check if installed
|
||||||
|
claude-mem gemini-cli uninstall # Remove hooks
|
||||||
|
|
||||||
|
For more info: https://docs.claude-mem.ai/usage/gemini-provider
|
||||||
|
`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* McpIntegrations - MCP-based IDE integrations for claude-mem
|
||||||
|
*
|
||||||
|
* Handles MCP config writing and context injection for IDEs that support
|
||||||
|
* the Model Context Protocol. These are "MCP-only" integrations: they provide
|
||||||
|
* search tools and context injection but do NOT capture transcripts.
|
||||||
|
*
|
||||||
|
* Supported IDEs:
|
||||||
|
* - Copilot CLI
|
||||||
|
* - Antigravity (Gemini)
|
||||||
|
* - Goose
|
||||||
|
* - Crush
|
||||||
|
* - Roo Code
|
||||||
|
* - Warp
|
||||||
|
*
|
||||||
|
* All IDEs point to the same MCP server: plugin/scripts/mcp-server.cjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { findMcpServerPath } from './CursorHooksInstaller.js';
|
||||||
|
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
import { injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const PLACEHOLDER_CONTEXT = `# claude-mem: Cross-Session Memory
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem's MCP search tools for manual memory queries.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the standard MCP server entry that all IDEs use.
|
||||||
|
* Points to the same mcp-server.cjs script.
|
||||||
|
*/
|
||||||
|
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [mcpServerPath],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a standard MCP JSON config file, merging with existing config.
|
||||||
|
* Supports both { "mcpServers": { ... } } and { "servers": { ... } } formats.
|
||||||
|
*/
|
||||||
|
function writeMcpJsonConfig(
|
||||||
|
configFilePath: string,
|
||||||
|
mcpServerPath: string,
|
||||||
|
serversKeyName: string = 'mcpServers',
|
||||||
|
): void {
|
||||||
|
const parentDirectory = path.dirname(configFilePath);
|
||||||
|
mkdirSync(parentDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const existingConfig = readJsonSafe<Record<string, any>>(configFilePath, {});
|
||||||
|
|
||||||
|
if (!existingConfig[serversKeyName]) {
|
||||||
|
existingConfig[serversKeyName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
existingConfig[serversKeyName]['claude-mem'] = buildMcpServerEntry(mcpServerPath);
|
||||||
|
|
||||||
|
writeFileSync(configFilePath, JSON.stringify(existingConfig, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCP Installer Factory (Phase 1D)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a JSON-based MCP IDE integration.
|
||||||
|
*/
|
||||||
|
interface McpInstallerConfig {
|
||||||
|
ideId: string;
|
||||||
|
ideLabel: string;
|
||||||
|
configPath: string;
|
||||||
|
configKey: 'servers' | 'mcpServers';
|
||||||
|
contextFile?: {
|
||||||
|
path: string;
|
||||||
|
isWorkspaceRelative: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function that creates an MCP installer for any JSON-config-based IDE.
|
||||||
|
* Handles MCP config writing and optional context injection.
|
||||||
|
*/
|
||||||
|
function installMcpIntegration(config: McpInstallerConfig): () => Promise<number> {
|
||||||
|
return async (): Promise<number> => {
|
||||||
|
console.log(`\nInstalling Claude-Mem MCP integration for ${config.ideLabel}...\n`);
|
||||||
|
|
||||||
|
const mcpServerPath = findMcpServerPath();
|
||||||
|
if (!mcpServerPath) {
|
||||||
|
console.error('Could not find MCP server script');
|
||||||
|
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write MCP config
|
||||||
|
const configPath = config.configPath;
|
||||||
|
|
||||||
|
// Warp special case: skip config write if ~/.warp/ doesn't exist
|
||||||
|
if (config.ideId === 'warp' && !existsSync(path.dirname(configPath))) {
|
||||||
|
console.log(` Note: ~/.warp/ not found. MCP may need to be configured via Warp Drive UI.`);
|
||||||
|
} else {
|
||||||
|
writeMcpJsonConfig(configPath, mcpServerPath, config.configKey);
|
||||||
|
console.log(` MCP config written to: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject context if configured
|
||||||
|
let contextPath: string | undefined;
|
||||||
|
if (config.contextFile) {
|
||||||
|
contextPath = config.contextFile.path;
|
||||||
|
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
|
||||||
|
console.log(` Context placeholder written to: ${contextPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
const summaryLines = [`\nInstallation complete!\n`];
|
||||||
|
summaryLines.push(`MCP config: ${configPath}`);
|
||||||
|
if (contextPath) {
|
||||||
|
summaryLines.push(`Context: ${contextPath}`);
|
||||||
|
}
|
||||||
|
summaryLines.push('');
|
||||||
|
summaryLines.push(`Note: This is an MCP-only integration providing search tools and context.`);
|
||||||
|
summaryLines.push(`Transcript capture is not available for ${config.ideLabel}.`);
|
||||||
|
if (config.ideId === 'warp') {
|
||||||
|
summaryLines.push('If MCP config via file is not supported, configure MCP through Warp Drive UI.');
|
||||||
|
}
|
||||||
|
summaryLines.push('');
|
||||||
|
summaryLines.push('Next steps:');
|
||||||
|
summaryLines.push(' 1. Start claude-mem worker: npx claude-mem start');
|
||||||
|
summaryLines.push(` 2. Restart ${config.ideLabel} to pick up the MCP server`);
|
||||||
|
summaryLines.push('');
|
||||||
|
console.log(summaryLines.join('\n'));
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Configs for JSON-based IDEs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const COPILOT_CLI_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'copilot-cli',
|
||||||
|
ideLabel: 'Copilot CLI',
|
||||||
|
configPath: path.join(homedir(), '.github', 'copilot', 'mcp.json'),
|
||||||
|
configKey: 'servers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.github', 'copilot-instructions.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANTIGRAVITY_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'antigravity',
|
||||||
|
ideLabel: 'Antigravity',
|
||||||
|
configPath: path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CRUSH_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'crush',
|
||||||
|
ideLabel: 'Crush',
|
||||||
|
configPath: path.join(homedir(), '.config', 'crush', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROO_CODE_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'roo-code',
|
||||||
|
ideLabel: 'Roo Code',
|
||||||
|
configPath: path.join(process.cwd(), '.roo', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.roo', 'rules', 'claude-mem-context.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const WARP_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'warp',
|
||||||
|
ideLabel: 'Warp',
|
||||||
|
configPath: path.join(homedir(), '.warp', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), 'WARP.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Goose (YAML-based — separate handler)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Goose config path.
|
||||||
|
* Goose stores its config at ~/.config/goose/config.yaml.
|
||||||
|
*/
|
||||||
|
function getGooseConfigPath(): string {
|
||||||
|
return path.join(homedir(), '.config', 'goose', 'config.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a YAML string already has a claude-mem entry under mcpServers.
|
||||||
|
* Uses string matching to avoid needing a YAML parser.
|
||||||
|
*/
|
||||||
|
function gooseConfigHasClaudeMemEntry(yamlContent: string): boolean {
|
||||||
|
// Look for "claude-mem:" indented under mcpServers
|
||||||
|
return yamlContent.includes('claude-mem:') &&
|
||||||
|
yamlContent.includes('mcpServers:');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Goose YAML MCP server block as a string.
|
||||||
|
* Produces properly indented YAML without needing a parser.
|
||||||
|
*/
|
||||||
|
function buildGooseMcpYamlBlock(mcpServerPath: string): string {
|
||||||
|
// Goose expects the mcpServers section at the top level
|
||||||
|
return [
|
||||||
|
'mcpServers:',
|
||||||
|
' claude-mem:',
|
||||||
|
` command: ${process.execPath}`,
|
||||||
|
' args:',
|
||||||
|
` - ${mcpServerPath}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build just the claude-mem server entry (for appending under existing mcpServers).
|
||||||
|
*/
|
||||||
|
function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string {
|
||||||
|
return [
|
||||||
|
' claude-mem:',
|
||||||
|
` command: ${process.execPath}`,
|
||||||
|
' args:',
|
||||||
|
` - ${mcpServerPath}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install claude-mem MCP integration for Goose.
|
||||||
|
*
|
||||||
|
* - Writes/merges MCP config into ~/.config/goose/config.yaml
|
||||||
|
* - Uses string manipulation for YAML (no parser dependency)
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installGooseMcpIntegration(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem MCP integration for Goose...\n');
|
||||||
|
|
||||||
|
const mcpServerPath = findMcpServerPath();
|
||||||
|
if (!mcpServerPath) {
|
||||||
|
console.error('Could not find MCP server script');
|
||||||
|
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configPath = getGooseConfigPath();
|
||||||
|
const configDirectory = path.dirname(configPath);
|
||||||
|
mkdirSync(configDirectory, { recursive: true });
|
||||||
|
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
let yamlContent = readFileSync(configPath, 'utf-8');
|
||||||
|
|
||||||
|
if (gooseConfigHasClaudeMemEntry(yamlContent)) {
|
||||||
|
// Already configured — replace the claude-mem block
|
||||||
|
// Find the claude-mem entry and replace it
|
||||||
|
const claudeMemPattern = /( {2}claude-mem:\n(?:.*\n)*?(?= {2}\S|\n\n|^\S|$))/m;
|
||||||
|
const newEntry = buildGooseClaudeMemEntryYaml(mcpServerPath) + '\n';
|
||||||
|
|
||||||
|
if (claudeMemPattern.test(yamlContent)) {
|
||||||
|
yamlContent = yamlContent.replace(claudeMemPattern, newEntry);
|
||||||
|
}
|
||||||
|
writeFileSync(configPath, yamlContent);
|
||||||
|
console.log(` Updated existing claude-mem entry in: ${configPath}`);
|
||||||
|
} else if (yamlContent.includes('mcpServers:')) {
|
||||||
|
// mcpServers section exists but no claude-mem entry — append under it
|
||||||
|
const mcpServersIndex = yamlContent.indexOf('mcpServers:');
|
||||||
|
const insertionPoint = mcpServersIndex + 'mcpServers:'.length;
|
||||||
|
const newEntry = '\n' + buildGooseClaudeMemEntryYaml(mcpServerPath);
|
||||||
|
|
||||||
|
yamlContent =
|
||||||
|
yamlContent.slice(0, insertionPoint) +
|
||||||
|
newEntry +
|
||||||
|
yamlContent.slice(insertionPoint);
|
||||||
|
|
||||||
|
writeFileSync(configPath, yamlContent);
|
||||||
|
console.log(` Added claude-mem to existing mcpServers in: ${configPath}`);
|
||||||
|
} else {
|
||||||
|
// No mcpServers section — append the entire block
|
||||||
|
const mcpBlock = '\n' + buildGooseMcpYamlBlock(mcpServerPath) + '\n';
|
||||||
|
yamlContent = yamlContent.trimEnd() + '\n' + mcpBlock;
|
||||||
|
writeFileSync(configPath, yamlContent);
|
||||||
|
console.log(` Appended mcpServers section to: ${configPath}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File doesn't exist — create from template
|
||||||
|
const templateContent = buildGooseMcpYamlBlock(mcpServerPath) + '\n';
|
||||||
|
writeFileSync(configPath, templateContent);
|
||||||
|
console.log(` Created config with MCP server: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
MCP config: ${configPath}
|
||||||
|
|
||||||
|
Note: This is an MCP-only integration providing search tools and context.
|
||||||
|
Transcript capture is not available for Goose.
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: npx claude-mem start
|
||||||
|
2. Restart Goose to pick up the MCP server
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Unified Installer (used by npx install command)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of IDE identifiers to their install functions.
|
||||||
|
* Used by the install command to dispatch to the correct integration.
|
||||||
|
*/
|
||||||
|
export const MCP_IDE_INSTALLERS: Record<string, () => Promise<number>> = {
|
||||||
|
'copilot-cli': installMcpIntegration(COPILOT_CLI_CONFIG),
|
||||||
|
'antigravity': installMcpIntegration(ANTIGRAVITY_CONFIG),
|
||||||
|
'goose': installGooseMcpIntegration,
|
||||||
|
'crush': installMcpIntegration(CRUSH_CONFIG),
|
||||||
|
'roo-code': installMcpIntegration(ROO_CODE_CONFIG),
|
||||||
|
'warp': installMcpIntegration(WARP_CONFIG),
|
||||||
|
};
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* OpenClawInstaller - OpenClaw gateway integration installer for claude-mem
|
||||||
|
*
|
||||||
|
* Installs the pre-built claude-mem plugin into OpenClaw's extension directory
|
||||||
|
* and registers it in ~/.openclaw/openclaw.json.
|
||||||
|
*
|
||||||
|
* Install strategy: File-based
|
||||||
|
* - Copies the pre-built plugin from the npm package's openclaw/dist/ directory
|
||||||
|
* to ~/.openclaw/extensions/claude-mem/dist/
|
||||||
|
* - Registers the plugin in openclaw.json under plugins.entries.claude-mem
|
||||||
|
* - Sets the memory slot to claude-mem
|
||||||
|
*
|
||||||
|
* Important: The OpenClaw plugin ships pre-built from the npm package.
|
||||||
|
* It must NOT be rebuilt at install time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
mkdirSync,
|
||||||
|
cpSync,
|
||||||
|
rmSync,
|
||||||
|
unlinkSync,
|
||||||
|
} from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Resolution
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenClaw config directory (~/.openclaw).
|
||||||
|
*/
|
||||||
|
export function getOpenClawConfigDirectory(): string {
|
||||||
|
return path.join(homedir(), '.openclaw');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenClaw extensions directory where plugins are installed.
|
||||||
|
*/
|
||||||
|
export function getOpenClawExtensionsDirectory(): string {
|
||||||
|
return path.join(getOpenClawConfigDirectory(), 'extensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the claude-mem extension install directory.
|
||||||
|
*/
|
||||||
|
export function getOpenClawClaudeMemExtensionDirectory(): string {
|
||||||
|
return path.join(getOpenClawExtensionsDirectory(), 'claude-mem');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the path to openclaw.json config file.
|
||||||
|
*/
|
||||||
|
export function getOpenClawConfigFilePath(): string {
|
||||||
|
return path.join(getOpenClawConfigDirectory(), 'openclaw.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pre-built Plugin Location
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the pre-built OpenClaw plugin bundle in the npm package.
|
||||||
|
* Searches in: openclaw/dist/index.js relative to package root,
|
||||||
|
* then the marketplace install location.
|
||||||
|
*/
|
||||||
|
export function findPreBuiltPluginDirectory(): string | null {
|
||||||
|
const possibleRoots = [
|
||||||
|
// Marketplace install location (production — after `npx claude-mem install`)
|
||||||
|
path.join(
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
|
||||||
|
'plugins', 'marketplaces', 'thedotmack',
|
||||||
|
),
|
||||||
|
// Development location (relative to project root)
|
||||||
|
process.cwd(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of possibleRoots) {
|
||||||
|
const openclawDistDirectory = path.join(root, 'openclaw', 'dist');
|
||||||
|
const pluginEntryPoint = path.join(openclawDistDirectory, 'index.js');
|
||||||
|
if (existsSync(pluginEntryPoint)) {
|
||||||
|
return openclawDistDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the openclaw.plugin.json file for copying alongside the plugin.
|
||||||
|
*/
|
||||||
|
export function findPluginManifestPath(): string | null {
|
||||||
|
const possibleRoots = [
|
||||||
|
path.join(
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
|
||||||
|
'plugins', 'marketplaces', 'thedotmack',
|
||||||
|
),
|
||||||
|
process.cwd(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of possibleRoots) {
|
||||||
|
const manifestPath = path.join(root, 'openclaw', 'openclaw.plugin.json');
|
||||||
|
if (existsSync(manifestPath)) {
|
||||||
|
return manifestPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the openclaw skills directory for copying alongside the plugin.
|
||||||
|
*/
|
||||||
|
export function findPluginSkillsDirectory(): string | null {
|
||||||
|
const possibleRoots = [
|
||||||
|
path.join(
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
|
||||||
|
'plugins', 'marketplaces', 'thedotmack',
|
||||||
|
),
|
||||||
|
process.cwd(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of possibleRoots) {
|
||||||
|
const skillsDirectory = path.join(root, 'openclaw', 'skills');
|
||||||
|
if (existsSync(skillsDirectory)) {
|
||||||
|
return skillsDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Config (openclaw.json) Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read openclaw.json safely, returning an empty object if missing or invalid.
|
||||||
|
*/
|
||||||
|
function readOpenClawConfig(): Record<string, any> {
|
||||||
|
const configFilePath = getOpenClawConfigFilePath();
|
||||||
|
if (!existsSync(configFilePath)) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(configFilePath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write openclaw.json atomically, creating the directory if needed.
|
||||||
|
*/
|
||||||
|
function writeOpenClawConfig(config: Record<string, any>): void {
|
||||||
|
const configDirectory = getOpenClawConfigDirectory();
|
||||||
|
mkdirSync(configDirectory, { recursive: true });
|
||||||
|
writeFileSync(getOpenClawConfigFilePath(), JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register claude-mem in openclaw.json by merging into the existing config.
|
||||||
|
* Does NOT overwrite the entire file -- only touches the claude-mem entry
|
||||||
|
* and the memory slot.
|
||||||
|
*/
|
||||||
|
function registerPluginInOpenClawConfig(
|
||||||
|
workerPort: number = 37777,
|
||||||
|
project: string = 'openclaw',
|
||||||
|
syncMemoryFile: boolean = true,
|
||||||
|
): void {
|
||||||
|
const config = readOpenClawConfig();
|
||||||
|
|
||||||
|
// Ensure the plugins structure exists
|
||||||
|
if (!config.plugins) config.plugins = {};
|
||||||
|
if (!config.plugins.slots) config.plugins.slots = {};
|
||||||
|
if (!config.plugins.entries) config.plugins.entries = {};
|
||||||
|
|
||||||
|
// Set the memory slot to claude-mem
|
||||||
|
config.plugins.slots.memory = 'claude-mem';
|
||||||
|
|
||||||
|
// Create or update the claude-mem plugin entry
|
||||||
|
if (!config.plugins.entries['claude-mem']) {
|
||||||
|
config.plugins.entries['claude-mem'] = {
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
workerPort,
|
||||||
|
project,
|
||||||
|
syncMemoryFile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Merge: enable and update config without losing existing user settings
|
||||||
|
config.plugins.entries['claude-mem'].enabled = true;
|
||||||
|
if (!config.plugins.entries['claude-mem'].config) {
|
||||||
|
config.plugins.entries['claude-mem'].config = {};
|
||||||
|
}
|
||||||
|
const existingPluginConfig = config.plugins.entries['claude-mem'].config;
|
||||||
|
// Only set defaults if not already configured
|
||||||
|
if (existingPluginConfig.workerPort === undefined) existingPluginConfig.workerPort = workerPort;
|
||||||
|
if (existingPluginConfig.project === undefined) existingPluginConfig.project = project;
|
||||||
|
if (existingPluginConfig.syncMemoryFile === undefined) existingPluginConfig.syncMemoryFile = syncMemoryFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeOpenClawConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove claude-mem from openclaw.json without deleting other config.
|
||||||
|
*/
|
||||||
|
function unregisterPluginFromOpenClawConfig(): void {
|
||||||
|
const configFilePath = getOpenClawConfigFilePath();
|
||||||
|
if (!existsSync(configFilePath)) return;
|
||||||
|
|
||||||
|
const config = readOpenClawConfig();
|
||||||
|
|
||||||
|
// Remove claude-mem entry
|
||||||
|
if (config.plugins?.entries?.['claude-mem']) {
|
||||||
|
delete config.plugins.entries['claude-mem'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear memory slot if it points to claude-mem
|
||||||
|
if (config.plugins?.slots?.memory === 'claude-mem') {
|
||||||
|
delete config.plugins.slots.memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeOpenClawConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin Installation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the claude-mem plugin into OpenClaw's extensions directory.
|
||||||
|
* Copies the pre-built plugin bundle and registers it in openclaw.json.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function installOpenClawPlugin(): number {
|
||||||
|
const preBuiltDistDirectory = findPreBuiltPluginDirectory();
|
||||||
|
if (!preBuiltDistDirectory) {
|
||||||
|
console.error('Could not find pre-built OpenClaw plugin bundle.');
|
||||||
|
console.error(' Expected at: openclaw/dist/index.js');
|
||||||
|
console.error(' Ensure the npm package includes the openclaw directory.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
|
||||||
|
const destinationDistDirectory = path.join(extensionDirectory, 'dist');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the extension directory structure
|
||||||
|
mkdirSync(destinationDistDirectory, { recursive: true });
|
||||||
|
|
||||||
|
// Copy pre-built dist files
|
||||||
|
cpSync(preBuiltDistDirectory, destinationDistDirectory, { recursive: true, force: true });
|
||||||
|
console.log(` Plugin dist copied to: ${destinationDistDirectory}`);
|
||||||
|
|
||||||
|
// Copy openclaw.plugin.json if available
|
||||||
|
const manifestPath = findPluginManifestPath();
|
||||||
|
if (manifestPath) {
|
||||||
|
const destinationManifest = path.join(extensionDirectory, 'openclaw.plugin.json');
|
||||||
|
cpSync(manifestPath, destinationManifest, { force: true });
|
||||||
|
console.log(` Plugin manifest copied to: ${destinationManifest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy skills directory if available
|
||||||
|
const skillsDirectory = findPluginSkillsDirectory();
|
||||||
|
if (skillsDirectory) {
|
||||||
|
const destinationSkills = path.join(extensionDirectory, 'skills');
|
||||||
|
cpSync(skillsDirectory, destinationSkills, { recursive: true, force: true });
|
||||||
|
console.log(` Skills copied to: ${destinationSkills}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a minimal package.json for the extension (OpenClaw expects this)
|
||||||
|
const extensionPackageJson = {
|
||||||
|
name: 'claude-mem',
|
||||||
|
version: '1.0.0',
|
||||||
|
type: 'module',
|
||||||
|
main: 'dist/index.js',
|
||||||
|
openclaw: { extensions: ['./dist/index.js'] },
|
||||||
|
};
|
||||||
|
writeFileSync(
|
||||||
|
path.join(extensionDirectory, 'package.json'),
|
||||||
|
JSON.stringify(extensionPackageJson, null, 2) + '\n',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register in openclaw.json (merge, not overwrite)
|
||||||
|
registerPluginInOpenClawConfig();
|
||||||
|
console.log(` Registered in openclaw.json`);
|
||||||
|
|
||||||
|
logger.info('OPENCLAW', 'Plugin installed', { destination: extensionDirectory });
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to install OpenClaw plugin: ${message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Uninstallation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the claude-mem plugin from OpenClaw.
|
||||||
|
* Removes extension files and unregisters from openclaw.json.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function uninstallOpenClawPlugin(): number {
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Remove extension directory
|
||||||
|
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
|
||||||
|
if (existsSync(extensionDirectory)) {
|
||||||
|
try {
|
||||||
|
rmSync(extensionDirectory, { recursive: true, force: true });
|
||||||
|
console.log(` Removed extension: ${extensionDirectory}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(` Failed to remove extension directory: ${message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister from openclaw.json
|
||||||
|
try {
|
||||||
|
unregisterPluginFromOpenClawConfig();
|
||||||
|
console.log(` Unregistered from openclaw.json`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(` Failed to update openclaw.json: ${message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasErrors ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Check
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check OpenClaw integration status.
|
||||||
|
*
|
||||||
|
* @returns 0 always (informational only)
|
||||||
|
*/
|
||||||
|
export function checkOpenClawStatus(): number {
|
||||||
|
console.log('\nClaude-Mem OpenClaw Integration Status\n');
|
||||||
|
|
||||||
|
const configDirectory = getOpenClawConfigDirectory();
|
||||||
|
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
|
||||||
|
const configFilePath = getOpenClawConfigFilePath();
|
||||||
|
const pluginEntryPoint = path.join(extensionDirectory, 'dist', 'index.js');
|
||||||
|
|
||||||
|
console.log(`Config directory: ${configDirectory}`);
|
||||||
|
console.log(` Exists: ${existsSync(configDirectory) ? 'yes' : 'no'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(`Extension directory: ${extensionDirectory}`);
|
||||||
|
console.log(` Exists: ${existsSync(extensionDirectory) ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Plugin entry: ${existsSync(pluginEntryPoint) ? 'yes' : 'no'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(`Config (openclaw.json): ${configFilePath}`);
|
||||||
|
if (existsSync(configFilePath)) {
|
||||||
|
const config = readOpenClawConfig();
|
||||||
|
const isRegistered = config.plugins?.entries?.['claude-mem'] !== undefined;
|
||||||
|
const isEnabled = config.plugins?.entries?.['claude-mem']?.enabled === true;
|
||||||
|
const isMemorySlot = config.plugins?.slots?.memory === 'claude-mem';
|
||||||
|
|
||||||
|
console.log(` Exists: yes`);
|
||||||
|
console.log(` Registered: ${isRegistered ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Enabled: ${isEnabled ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Memory slot: ${isMemorySlot ? 'yes' : 'no'}`);
|
||||||
|
|
||||||
|
if (isRegistered) {
|
||||||
|
const pluginConfig = config.plugins.entries['claude-mem'].config;
|
||||||
|
if (pluginConfig) {
|
||||||
|
console.log(` Worker port: ${pluginConfig.workerPort ?? 'default'}`);
|
||||||
|
console.log(` Project: ${pluginConfig.project ?? 'default'}`);
|
||||||
|
console.log(` Sync MEMORY.md: ${pluginConfig.syncMemoryFile ?? 'default'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` Exists: no`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Full Install Flow (used by npx install command)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full OpenClaw installation: copy plugin + register in config.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installOpenClawIntegration(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem for OpenClaw...\n');
|
||||||
|
|
||||||
|
// Step 1: Install plugin files and register in config
|
||||||
|
const pluginResult = installOpenClawPlugin();
|
||||||
|
if (pluginResult !== 0) {
|
||||||
|
return pluginResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Plugin installed to: ${extensionDirectory}
|
||||||
|
Config updated: ${getOpenClawConfigFilePath()}
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: npx claude-mem start
|
||||||
|
2. Restart OpenClaw to load the plugin
|
||||||
|
3. Memory capture is automatic from then on
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* OpenCodeInstaller - OpenCode IDE integration installer for claude-mem
|
||||||
|
*
|
||||||
|
* Installs the claude-mem plugin into OpenCode's plugin directory and
|
||||||
|
* sets up context injection via AGENTS.md.
|
||||||
|
*
|
||||||
|
* Install strategy: File-based (Option A)
|
||||||
|
* - Copies the built plugin to the OpenCode plugins directory
|
||||||
|
* - Plugins in that directory are auto-loaded at startup
|
||||||
|
*
|
||||||
|
* Context injection:
|
||||||
|
* - Appends/updates <claude-mem-context> section in AGENTS.md
|
||||||
|
*
|
||||||
|
* Respects OPENCODE_CONFIG_DIR env var for config directory resolution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE, injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
|
||||||
|
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Resolution
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenCode config directory.
|
||||||
|
* Respects OPENCODE_CONFIG_DIR env var, falls back to ~/.config/opencode.
|
||||||
|
*/
|
||||||
|
export function getOpenCodeConfigDirectory(): string {
|
||||||
|
if (process.env.OPENCODE_CONFIG_DIR) {
|
||||||
|
return process.env.OPENCODE_CONFIG_DIR;
|
||||||
|
}
|
||||||
|
return path.join(homedir(), '.config', 'opencode');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenCode plugins directory.
|
||||||
|
*/
|
||||||
|
export function getOpenCodePluginsDirectory(): string {
|
||||||
|
return path.join(getOpenCodeConfigDirectory(), 'plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the AGENTS.md path for context injection.
|
||||||
|
*/
|
||||||
|
export function getOpenCodeAgentsMdPath(): string {
|
||||||
|
return path.join(getOpenCodeConfigDirectory(), 'AGENTS.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the path to the installed plugin file.
|
||||||
|
*/
|
||||||
|
export function getInstalledPluginPath(): string {
|
||||||
|
return path.join(getOpenCodePluginsDirectory(), 'claude-mem.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin Installation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the built OpenCode plugin bundle.
|
||||||
|
* Searches in: dist/opencode-plugin/index.js (built output),
|
||||||
|
* then marketplace location.
|
||||||
|
*/
|
||||||
|
export function findBuiltPluginPath(): string | null {
|
||||||
|
const possiblePaths = [
|
||||||
|
// Marketplace install location (production)
|
||||||
|
path.join(
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
|
||||||
|
'plugins', 'marketplaces', 'thedotmack',
|
||||||
|
'dist', 'opencode-plugin', 'index.js',
|
||||||
|
),
|
||||||
|
// Development location (relative to this module's package root)
|
||||||
|
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'dist', 'opencode-plugin', 'index.js'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidatePath of possiblePaths) {
|
||||||
|
if (existsSync(candidatePath)) {
|
||||||
|
return candidatePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the claude-mem plugin into OpenCode's plugins directory.
|
||||||
|
* Copies the built plugin bundle to ~/.config/opencode/plugins/claude-mem.js
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function installOpenCodePlugin(): number {
|
||||||
|
const builtPluginPath = findBuiltPluginPath();
|
||||||
|
if (!builtPluginPath) {
|
||||||
|
console.error('Could not find built OpenCode plugin bundle.');
|
||||||
|
console.error(' Expected at: dist/opencode-plugin/index.js');
|
||||||
|
console.error(' Run the build first: npm run build');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsDirectory = getOpenCodePluginsDirectory();
|
||||||
|
const destinationPath = getInstalledPluginPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create plugins directory if needed
|
||||||
|
mkdirSync(pluginsDirectory, { recursive: true });
|
||||||
|
|
||||||
|
// Copy plugin bundle
|
||||||
|
copyFileSync(builtPluginPath, destinationPath);
|
||||||
|
|
||||||
|
console.log(` Plugin installed to: ${destinationPath}`);
|
||||||
|
logger.info('OPENCODE', 'Plugin installed', { destination: destinationPath });
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to install OpenCode plugin: ${message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context Injection (AGENTS.md)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject or update claude-mem context in OpenCode's AGENTS.md file.
|
||||||
|
*
|
||||||
|
* If the file doesn't exist, creates it with the context section.
|
||||||
|
* If the file exists, replaces the existing <claude-mem-context> section
|
||||||
|
* or appends one at the end.
|
||||||
|
*
|
||||||
|
* @param contextContent - The context content to inject (without tags)
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function injectContextIntoAgentsMd(contextContent: string): number {
|
||||||
|
const agentsMdPath = getOpenCodeAgentsMdPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
injectContextIntoMarkdownFile(agentsMdPath, contextContent, '# Claude-Mem Memory Context');
|
||||||
|
logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath });
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to inject context into AGENTS.md: ${message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync context from the worker into OpenCode's AGENTS.md.
|
||||||
|
* Fetches context from the worker API and writes it to AGENTS.md.
|
||||||
|
*
|
||||||
|
* @param port - Worker port number
|
||||||
|
* @param project - Project name for context filtering
|
||||||
|
*/
|
||||||
|
export async function syncContextToAgentsMd(
|
||||||
|
port: number,
|
||||||
|
project: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const contextText = await response.text();
|
||||||
|
if (contextText && contextText.trim()) {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(contextText);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject context into AGENTS.md during sync');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Worker not available — non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Uninstallation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the claude-mem plugin from OpenCode.
|
||||||
|
* Removes the plugin file and cleans up the AGENTS.md context section.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function uninstallOpenCodePlugin(): number {
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Remove plugin file
|
||||||
|
const pluginPath = getInstalledPluginPath();
|
||||||
|
if (existsSync(pluginPath)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(pluginPath);
|
||||||
|
console.log(` Removed plugin: ${pluginPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(` Failed to remove plugin: ${message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove context section from AGENTS.md
|
||||||
|
const agentsMdPath = getOpenCodeAgentsMdPath();
|
||||||
|
if (existsSync(agentsMdPath)) {
|
||||||
|
try {
|
||||||
|
let content = readFileSync(agentsMdPath, 'utf-8');
|
||||||
|
const tagStartIndex = content.indexOf(CONTEXT_TAG_OPEN);
|
||||||
|
const tagEndIndex = content.indexOf(CONTEXT_TAG_CLOSE);
|
||||||
|
|
||||||
|
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
|
||||||
|
content =
|
||||||
|
content.slice(0, tagStartIndex).trimEnd() +
|
||||||
|
'\n' +
|
||||||
|
content.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length).trimStart();
|
||||||
|
|
||||||
|
// If the file is now essentially empty or only has our header, remove it
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (
|
||||||
|
trimmedContent.length === 0 ||
|
||||||
|
trimmedContent === '# Claude-Mem Memory Context'
|
||||||
|
) {
|
||||||
|
unlinkSync(agentsMdPath);
|
||||||
|
console.log(` Removed empty AGENTS.md`);
|
||||||
|
} else {
|
||||||
|
writeFileSync(agentsMdPath, trimmedContent + '\n', 'utf-8');
|
||||||
|
console.log(` Cleaned context from AGENTS.md`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(` Failed to clean AGENTS.md: ${message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasErrors ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Check
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check OpenCode integration status.
|
||||||
|
*
|
||||||
|
* @returns 0 always (informational only)
|
||||||
|
*/
|
||||||
|
export function checkOpenCodeStatus(): number {
|
||||||
|
console.log('\nClaude-Mem OpenCode Integration Status\n');
|
||||||
|
|
||||||
|
const configDirectory = getOpenCodeConfigDirectory();
|
||||||
|
const pluginPath = getInstalledPluginPath();
|
||||||
|
const agentsMdPath = getOpenCodeAgentsMdPath();
|
||||||
|
|
||||||
|
console.log(`Config directory: ${configDirectory}`);
|
||||||
|
console.log(` Exists: ${existsSync(configDirectory) ? 'yes' : 'no'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(`Plugin: ${pluginPath}`);
|
||||||
|
console.log(` Installed: ${existsSync(pluginPath) ? 'yes' : 'no'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(`Context (AGENTS.md): ${agentsMdPath}`);
|
||||||
|
if (existsSync(agentsMdPath)) {
|
||||||
|
const content = readFileSync(agentsMdPath, 'utf-8');
|
||||||
|
const hasContextTags = content.includes(CONTEXT_TAG_OPEN);
|
||||||
|
console.log(` Exists: yes`);
|
||||||
|
console.log(` Has claude-mem context: ${hasContextTags ? 'yes' : 'no'}`);
|
||||||
|
} else {
|
||||||
|
console.log(` Exists: no`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Full Install Flow (used by npx install command)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full OpenCode installation: plugin + context injection.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installOpenCodeIntegration(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem for OpenCode...\n');
|
||||||
|
|
||||||
|
// Step 1: Install plugin
|
||||||
|
const pluginResult = installOpenCodePlugin();
|
||||||
|
if (pluginResult !== 0) {
|
||||||
|
return pluginResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create initial context in AGENTS.md
|
||||||
|
const placeholderContext = `# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem search tools for manual memory queries.`;
|
||||||
|
|
||||||
|
// Try to fetch real context from worker first
|
||||||
|
try {
|
||||||
|
const workerPort = getWorkerPort();
|
||||||
|
const healthResponse = await fetch(`http://127.0.0.1:${workerPort}/api/readiness`);
|
||||||
|
if (healthResponse.ok) {
|
||||||
|
const contextResponse = await fetch(
|
||||||
|
`http://127.0.0.1:${workerPort}/api/context/inject?project=opencode`,
|
||||||
|
);
|
||||||
|
if (contextResponse.ok) {
|
||||||
|
const realContext = await contextResponse.text();
|
||||||
|
if (realContext && realContext.trim()) {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(realContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject real context into AGENTS.md during install');
|
||||||
|
} else {
|
||||||
|
console.log(' Context injected from existing memory');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(placeholderContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
|
||||||
|
} else {
|
||||||
|
console.log(' Placeholder context created (will populate after first session)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(placeholderContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(placeholderContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
|
||||||
|
} else {
|
||||||
|
console.log(' Placeholder context created (worker not running)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(placeholderContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
|
||||||
|
} else {
|
||||||
|
console.log(' Placeholder context created (worker not running)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Plugin installed to: ${getInstalledPluginPath()}
|
||||||
|
Context file: ${getOpenCodeAgentsMdPath()}
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: npx claude-mem start
|
||||||
|
2. Restart OpenCode to load the plugin
|
||||||
|
3. Memory capture is automatic from then on
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
/**
|
||||||
|
* WindsurfHooksInstaller - Windsurf IDE integration for claude-mem
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Windsurf hooks installation/uninstallation to ~/.codeium/windsurf/hooks.json
|
||||||
|
* - Context file generation (.windsurf/rules/claude-mem-context.md)
|
||||||
|
* - Project registry management for auto-context updates
|
||||||
|
*
|
||||||
|
* Windsurf hooks.json format:
|
||||||
|
* {
|
||||||
|
* "hooks": {
|
||||||
|
* "<event_name>": [{ "command": "...", "show_output": false, "working_directory": "..." }]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Events registered (all post-action, non-blocking):
|
||||||
|
* - pre_user_prompt — session init + context injection
|
||||||
|
* - post_write_code — code generation observation
|
||||||
|
* - post_run_command — command execution observation
|
||||||
|
* - post_mcp_tool_use — MCP tool results
|
||||||
|
* - post_cascade_response — full AI response
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||||
|
import { DATA_DIR } from '../../shared/paths.js';
|
||||||
|
import { findBunPath, findWorkerServicePath } from './CursorHooksInstaller.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface WindsurfHookEntry {
|
||||||
|
command: string;
|
||||||
|
show_output: boolean;
|
||||||
|
working_directory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindsurfHooksJson {
|
||||||
|
hooks: {
|
||||||
|
[eventName: string]: WindsurfHookEntry[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindsurfProjectRegistry {
|
||||||
|
[workspacePath: string]: {
|
||||||
|
installedAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** User-level hooks config — global coverage across all Windsurf workspaces */
|
||||||
|
const WINDSURF_HOOKS_DIR = path.join(homedir(), '.codeium', 'windsurf');
|
||||||
|
const WINDSURF_HOOKS_JSON_PATH = path.join(WINDSURF_HOOKS_DIR, 'hooks.json');
|
||||||
|
|
||||||
|
/** Windsurf context rule limit: 6,000 chars per file */
|
||||||
|
const WINDSURF_CONTEXT_CHAR_LIMIT = 6000;
|
||||||
|
|
||||||
|
/** Registry file for tracking projects with Windsurf hooks */
|
||||||
|
const WINDSURF_REGISTRY_FILE = path.join(DATA_DIR, 'windsurf-projects.json');
|
||||||
|
|
||||||
|
/** Hook events we register */
|
||||||
|
const WINDSURF_HOOK_EVENTS = [
|
||||||
|
'pre_user_prompt',
|
||||||
|
'post_write_code',
|
||||||
|
'post_run_command',
|
||||||
|
'post_mcp_tool_use',
|
||||||
|
'post_cascade_response',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Project Registry
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the Windsurf project registry
|
||||||
|
*/
|
||||||
|
export function readWindsurfRegistry(): WindsurfProjectRegistry {
|
||||||
|
try {
|
||||||
|
if (!existsSync(WINDSURF_REGISTRY_FILE)) return {};
|
||||||
|
return JSON.parse(readFileSync(WINDSURF_REGISTRY_FILE, 'utf-8'));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('WINDSURF', 'Failed to read registry, using empty', {
|
||||||
|
file: WINDSURF_REGISTRY_FILE,
|
||||||
|
}, error as Error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the Windsurf project registry
|
||||||
|
*/
|
||||||
|
export function writeWindsurfRegistry(registry: WindsurfProjectRegistry): void {
|
||||||
|
const dir = path.dirname(WINDSURF_REGISTRY_FILE);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(WINDSURF_REGISTRY_FILE, JSON.stringify(registry, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a project for auto-context updates.
|
||||||
|
* Keys by full workspacePath to avoid collisions between directories with the same basename.
|
||||||
|
*/
|
||||||
|
export function registerWindsurfProject(workspacePath: string): void {
|
||||||
|
const registry = readWindsurfRegistry();
|
||||||
|
registry[workspacePath] = {
|
||||||
|
installedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeWindsurfRegistry(registry);
|
||||||
|
logger.info('WINDSURF', 'Registered project for auto-context updates', { workspacePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a project from auto-context updates
|
||||||
|
*/
|
||||||
|
export function unregisterWindsurfProject(workspacePath: string): void {
|
||||||
|
const registry = readWindsurfRegistry();
|
||||||
|
if (registry[workspacePath]) {
|
||||||
|
delete registry[workspacePath];
|
||||||
|
writeWindsurfRegistry(registry);
|
||||||
|
logger.info('WINDSURF', 'Unregistered project', { workspacePath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Windsurf context files for a registered project.
|
||||||
|
* Called by SDK agents after saving a summary.
|
||||||
|
*/
|
||||||
|
export async function updateWindsurfContextForProject(projectName: string, workspacePath: string, port: number): Promise<void> {
|
||||||
|
const registry = readWindsurfRegistry();
|
||||||
|
const entry = registry[workspacePath];
|
||||||
|
|
||||||
|
if (!entry) return; // Project doesn't have Windsurf hooks installed
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const context = await response.text();
|
||||||
|
if (!context || !context.trim()) return;
|
||||||
|
|
||||||
|
writeWindsurfContextFile(workspacePath, context);
|
||||||
|
logger.debug('WINDSURF', 'Updated context file', { projectName, workspacePath });
|
||||||
|
} catch (error) {
|
||||||
|
// Background context update — failure is non-critical
|
||||||
|
logger.error('WINDSURF', 'Failed to update context file', { projectName, workspacePath }, error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context File
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write context to the workspace-level Windsurf rules directory.
|
||||||
|
* Windsurf rules are workspace-scoped: .windsurf/rules/claude-mem-context.md
|
||||||
|
* Rule file limit: 6,000 chars per file.
|
||||||
|
*/
|
||||||
|
export function writeWindsurfContextFile(workspacePath: string, context: string): void {
|
||||||
|
const rulesDir = path.join(workspacePath, '.windsurf', 'rules');
|
||||||
|
const rulesFile = path.join(rulesDir, 'claude-mem-context.md');
|
||||||
|
const tempFile = `${rulesFile}.tmp`;
|
||||||
|
|
||||||
|
mkdirSync(rulesDir, { recursive: true });
|
||||||
|
|
||||||
|
let content = `# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||||
|
|
||||||
|
${context}
|
||||||
|
|
||||||
|
---
|
||||||
|
*Auto-updated by claude-mem after each session. Use MCP search tools for detailed queries.*
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Enforce Windsurf's 6K char limit
|
||||||
|
if (content.length > WINDSURF_CONTEXT_CHAR_LIMIT) {
|
||||||
|
content = content.slice(0, WINDSURF_CONTEXT_CHAR_LIMIT - 50) +
|
||||||
|
'\n\n*[Truncated — use MCP search for full history]*\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write: temp file + rename
|
||||||
|
writeFileSync(tempFile, content);
|
||||||
|
renameSync(tempFile, rulesFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook Installation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the hook command string for a given event.
|
||||||
|
* Uses bun to run worker-service.cjs with the windsurf platform adapter.
|
||||||
|
*/
|
||||||
|
function buildHookCommand(bunPath: string, workerServicePath: string, eventName: string): string {
|
||||||
|
// Map Windsurf event names to unified CLI hook commands
|
||||||
|
const eventToCommand: Record<string, string> = {
|
||||||
|
'pre_user_prompt': 'session-init',
|
||||||
|
'post_write_code': 'file-edit',
|
||||||
|
'post_run_command': 'observation',
|
||||||
|
'post_mcp_tool_use': 'observation',
|
||||||
|
'post_cascade_response': 'observation',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hookCommand = eventToCommand[eventName] ?? 'observation';
|
||||||
|
|
||||||
|
return `"${bunPath}" "${workerServicePath}" hook windsurf ${hookCommand}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read existing hooks.json, merge our hooks, and write back.
|
||||||
|
* Preserves any existing hooks from other tools.
|
||||||
|
*/
|
||||||
|
function mergeAndWriteHooksJson(
|
||||||
|
bunPath: string,
|
||||||
|
workerServicePath: string,
|
||||||
|
workingDirectory: string,
|
||||||
|
): void {
|
||||||
|
mkdirSync(WINDSURF_HOOKS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Read existing hooks.json if present
|
||||||
|
let existingConfig: WindsurfHooksJson = { hooks: {} };
|
||||||
|
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
|
||||||
|
try {
|
||||||
|
existingConfig = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
|
||||||
|
if (!existingConfig.hooks) {
|
||||||
|
existingConfig.hooks = {};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Corrupt hooks.json at ${WINDSURF_HOOKS_JSON_PATH}, refusing to overwrite`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each event, add our hook entry (remove any previous claude-mem entries first)
|
||||||
|
for (const eventName of WINDSURF_HOOK_EVENTS) {
|
||||||
|
const command = buildHookCommand(bunPath, workerServicePath, eventName);
|
||||||
|
|
||||||
|
const hookEntry: WindsurfHookEntry = {
|
||||||
|
command,
|
||||||
|
show_output: false,
|
||||||
|
working_directory: workingDirectory,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get existing hooks for this event, filtering out old claude-mem ones
|
||||||
|
const existingHooks = (existingConfig.hooks[eventName] ?? []).filter(
|
||||||
|
(hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf')
|
||||||
|
);
|
||||||
|
|
||||||
|
existingConfig.hooks[eventName] = [...existingHooks, hookEntry];
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(existingConfig, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Windsurf hooks to ~/.codeium/windsurf/hooks.json (user-level).
|
||||||
|
* Merges with existing hooks.json to preserve other integrations.
|
||||||
|
*/
|
||||||
|
export async function installWindsurfHooks(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem Windsurf hooks (user level)...\n');
|
||||||
|
|
||||||
|
// Find the worker-service.cjs path
|
||||||
|
const workerServicePath = findWorkerServicePath();
|
||||||
|
if (!workerServicePath) {
|
||||||
|
console.error('Could not find worker-service.cjs');
|
||||||
|
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find bun executable — required because worker-service.cjs uses bun:sqlite
|
||||||
|
const bunPath = findBunPath();
|
||||||
|
if (!bunPath) {
|
||||||
|
console.error('Could not find Bun runtime');
|
||||||
|
console.error(' Install Bun: curl -fsSL https://bun.sh/install | bash');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Tilde expansion is NOT supported in working_directory — use absolute paths
|
||||||
|
const workingDirectory = path.dirname(workerServicePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(` Using Bun runtime: ${bunPath}`);
|
||||||
|
console.log(` Worker service: ${workerServicePath}`);
|
||||||
|
|
||||||
|
// Merge our hooks into the existing hooks.json
|
||||||
|
mergeAndWriteHooksJson(bunPath, workerServicePath, workingDirectory);
|
||||||
|
console.log(` Created/merged hooks.json`);
|
||||||
|
|
||||||
|
// Set up initial context for the current workspace
|
||||||
|
const workspaceRoot = process.cwd();
|
||||||
|
await setupWindsurfProjectContext(workspaceRoot);
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Hooks installed to: ${WINDSURF_HOOKS_JSON_PATH}
|
||||||
|
Using unified CLI: bun worker-service.cjs hook windsurf <command>
|
||||||
|
|
||||||
|
Events registered:
|
||||||
|
- pre_user_prompt (session init + context injection)
|
||||||
|
- post_write_code (code generation observation)
|
||||||
|
- post_run_command (command execution observation)
|
||||||
|
- post_mcp_tool_use (MCP tool results)
|
||||||
|
- post_cascade_response (full AI response)
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: claude-mem start
|
||||||
|
2. Restart Windsurf to load the hooks
|
||||||
|
3. Context is injected via .windsurf/rules/claude-mem-context.md (workspace-level)
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup initial context file for a Windsurf workspace
|
||||||
|
*/
|
||||||
|
async function setupWindsurfProjectContext(workspaceRoot: string): Promise<void> {
|
||||||
|
const port = getWorkerPort();
|
||||||
|
const projectName = path.basename(workspaceRoot);
|
||||||
|
let contextGenerated = false;
|
||||||
|
|
||||||
|
console.log(` Generating initial context...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`);
|
||||||
|
if (healthResponse.ok) {
|
||||||
|
const contextResponse = await fetch(
|
||||||
|
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
|
||||||
|
);
|
||||||
|
if (contextResponse.ok) {
|
||||||
|
const context = await contextResponse.text();
|
||||||
|
if (context && context.trim()) {
|
||||||
|
writeWindsurfContextFile(workspaceRoot, context);
|
||||||
|
contextGenerated = true;
|
||||||
|
console.log(` Generated initial context from existing memory`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Worker not running during install — non-critical
|
||||||
|
logger.debug('WINDSURF', 'Worker not running during install', {}, error as Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contextGenerated) {
|
||||||
|
// Create placeholder context file
|
||||||
|
const rulesDir = path.join(workspaceRoot, '.windsurf', 'rules');
|
||||||
|
mkdirSync(rulesDir, { recursive: true });
|
||||||
|
const rulesFile = path.join(rulesDir, 'claude-mem-context.md');
|
||||||
|
const placeholderContent = `# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem's MCP search tools for manual memory queries.
|
||||||
|
`;
|
||||||
|
writeFileSync(rulesFile, placeholderContent);
|
||||||
|
console.log(` Created placeholder context file (will populate after first session)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register project for automatic context updates after summaries
|
||||||
|
registerWindsurfProject(workspaceRoot);
|
||||||
|
console.log(` Registered for auto-context updates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstall Windsurf hooks — removes claude-mem entries from hooks.json
|
||||||
|
*/
|
||||||
|
export function uninstallWindsurfHooks(): number {
|
||||||
|
console.log('\nUninstalling Claude-Mem Windsurf hooks...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove our entries from hooks.json (preserve other integrations)
|
||||||
|
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
|
||||||
|
try {
|
||||||
|
const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
|
||||||
|
|
||||||
|
for (const eventName of WINDSURF_HOOK_EVENTS) {
|
||||||
|
if (config.hooks[eventName]) {
|
||||||
|
config.hooks[eventName] = config.hooks[eventName].filter(
|
||||||
|
(hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf')
|
||||||
|
);
|
||||||
|
// Remove empty arrays
|
||||||
|
if (config.hooks[eventName].length === 0) {
|
||||||
|
delete config.hooks[eventName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no hooks remain, remove the file entirely
|
||||||
|
if (Object.keys(config.hooks).length === 0) {
|
||||||
|
unlinkSync(WINDSURF_HOOKS_JSON_PATH);
|
||||||
|
console.log(` Removed hooks.json (no hooks remaining)`);
|
||||||
|
} else {
|
||||||
|
writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(config, null, 2));
|
||||||
|
console.log(` Removed claude-mem entries from hooks.json (other hooks preserved)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Warning: could not parse hooks.json — leaving file intact to preserve other hooks`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` No hooks.json found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove context file from the current workspace
|
||||||
|
const workspaceRoot = process.cwd();
|
||||||
|
const contextFile = path.join(workspaceRoot, '.windsurf', 'rules', 'claude-mem-context.md');
|
||||||
|
if (existsSync(contextFile)) {
|
||||||
|
unlinkSync(contextFile);
|
||||||
|
console.log(` Removed context file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister project
|
||||||
|
unregisterWindsurfProject(workspaceRoot);
|
||||||
|
console.log(` Unregistered from auto-context updates`);
|
||||||
|
|
||||||
|
console.log(`\nUninstallation complete!\n`);
|
||||||
|
console.log('Restart Windsurf to apply changes.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nUninstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Windsurf hooks installation status
|
||||||
|
*/
|
||||||
|
export function checkWindsurfHooksStatus(): number {
|
||||||
|
console.log('\nClaude-Mem Windsurf Hooks Status\n');
|
||||||
|
|
||||||
|
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
|
||||||
|
console.log(`User-level: Installed`);
|
||||||
|
console.log(` Config: ${WINDSURF_HOOKS_JSON_PATH}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
|
||||||
|
const registeredEvents = WINDSURF_HOOK_EVENTS.filter(
|
||||||
|
(event) => config.hooks[event]?.some(
|
||||||
|
(hook) => hook.command.includes('worker-service') && hook.command.includes('windsurf')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log(` Events: ${registeredEvents.length}/${WINDSURF_HOOK_EVENTS.length} registered`);
|
||||||
|
for (const event of registeredEvents) {
|
||||||
|
console.log(` - ${event}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(` Mode: Unable to parse hooks.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for context file in current workspace
|
||||||
|
const contextFile = path.join(process.cwd(), '.windsurf', 'rules', 'claude-mem-context.md');
|
||||||
|
if (existsSync(contextFile)) {
|
||||||
|
console.log(` Context: Active (current workspace)`);
|
||||||
|
} else {
|
||||||
|
console.log(` Context: Not yet generated for this workspace`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`User-level: Not installed`);
|
||||||
|
console.log(`\nNo hooks installed. Run: claude-mem windsurf install\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle windsurf subcommand for hooks installation
|
||||||
|
*/
|
||||||
|
export async function handleWindsurfCommand(subcommand: string, _args: string[]): Promise<number> {
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'install':
|
||||||
|
return installWindsurfHooks();
|
||||||
|
|
||||||
|
case 'uninstall':
|
||||||
|
return uninstallWindsurfHooks();
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
return checkWindsurfHooksStatus();
|
||||||
|
|
||||||
|
default: {
|
||||||
|
console.log(`
|
||||||
|
Claude-Mem Windsurf Integration
|
||||||
|
|
||||||
|
Usage: claude-mem windsurf <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Install Windsurf hooks (user-level, ~/.codeium/windsurf/hooks.json)
|
||||||
|
uninstall Remove Windsurf hooks
|
||||||
|
status Check installation status
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
claude-mem windsurf install # Install hooks globally
|
||||||
|
claude-mem windsurf uninstall # Remove hooks
|
||||||
|
claude-mem windsurf status # Check if hooks are installed
|
||||||
|
|
||||||
|
For more info: https://docs.claude-mem.ai/windsurf
|
||||||
|
`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Integrations module - IDE integrations (Cursor, etc.)
|
* Integrations module - IDE integrations (Cursor, Gemini CLI, OpenCode, Windsurf, etc.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
export * from './CursorHooksInstaller.js';
|
export * from './CursorHooksInstaller.js';
|
||||||
|
export * from './GeminiCliHooksInstaller.js';
|
||||||
|
export * from './OpenCodeInstaller.js';
|
||||||
|
export * from './WindsurfHooksInstaller.js';
|
||||||
|
export * from './OpenClawInstaller.js';
|
||||||
|
export * from './CodexCliInstaller.js';
|
||||||
|
export * from './McpIntegrations.js';
|
||||||
|
|||||||
@@ -397,6 +397,19 @@ export class PendingMessageStore {
|
|||||||
return result.count;
|
return result.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peek at pending message types for a session (for tier routing).
|
||||||
|
* Returns list of { message_type, tool_name } without claiming.
|
||||||
|
*/
|
||||||
|
peekPendingTypes(sessionDbId: number): Array<{ message_type: string; tool_name: string | null }> {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT message_type, tool_name FROM pending_messages
|
||||||
|
WHERE session_db_id = ? AND status IN ('pending', 'processing')
|
||||||
|
ORDER BY id ASC
|
||||||
|
`);
|
||||||
|
return stmt.all(sessionDbId) as Array<{ message_type: string; tool_name: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if any session has pending work.
|
* Check if any session has pending work.
|
||||||
* Excludes 'processing' messages stuck for >5 minutes (resets them to 'pending' as a side effect).
|
* Excludes 'processing' messages stuck for >5 minutes (resets them to 'pending' as a side effect).
|
||||||
|
|||||||
@@ -509,6 +509,38 @@ export const migration007: Migration = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All migrations in order
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Migration 008: Observation feedback table for tracking observation usage
|
||||||
|
*
|
||||||
|
* Tracks how observations are used (semantic injection hits, search access,
|
||||||
|
* explicit retrieval). Foundation for future Thompson Sampling optimization.
|
||||||
|
*/
|
||||||
|
export const migration008: Migration = {
|
||||||
|
version: 25,
|
||||||
|
up: (db: Database) => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS observation_feedback (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
observation_id INTEGER NOT NULL,
|
||||||
|
signal_type TEXT NOT NULL,
|
||||||
|
session_db_id INTEGER,
|
||||||
|
created_at_epoch INTEGER NOT NULL,
|
||||||
|
metadata TEXT,
|
||||||
|
FOREIGN KEY (observation_id) REFERENCES observations(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_feedback_observation ON observation_feedback(observation_id)`);
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_feedback_signal ON observation_feedback(signal_type)`);
|
||||||
|
console.log('✅ Created observation_feedback table for usage tracking');
|
||||||
|
},
|
||||||
|
down: (db: Database) => {
|
||||||
|
db.run(`DROP TABLE IF EXISTS observation_feedback`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All migrations in order
|
* All migrations in order
|
||||||
*/
|
*/
|
||||||
@@ -519,5 +551,6 @@ export const migrations: Migration[] = [
|
|||||||
migration004,
|
migration004,
|
||||||
migration005,
|
migration005,
|
||||||
migration006,
|
migration006,
|
||||||
migration007
|
migration007,
|
||||||
|
migration008
|
||||||
];
|
];
|
||||||
@@ -34,6 +34,7 @@ export class MigrationRunner {
|
|||||||
this.addOnUpdateCascadeToForeignKeys();
|
this.addOnUpdateCascadeToForeignKeys();
|
||||||
this.addObservationContentHashColumn();
|
this.addObservationContentHashColumn();
|
||||||
this.addSessionCustomTitleColumn();
|
this.addSessionCustomTitleColumn();
|
||||||
|
this.createObservationFeedbackTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -863,4 +864,31 @@ export class MigrationRunner {
|
|||||||
|
|
||||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
|
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create observation_feedback table for tracking observation usage signals.
|
||||||
|
* Foundation for tier routing optimization and future Thompson Sampling.
|
||||||
|
* Schema version 24.
|
||||||
|
*/
|
||||||
|
private createObservationFeedbackTable(): void {
|
||||||
|
const applied = this.db.query('SELECT 1 FROM schema_versions WHERE version = 24').get();
|
||||||
|
if (applied) return;
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS observation_feedback (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
observation_id INTEGER NOT NULL,
|
||||||
|
signal_type TEXT NOT NULL,
|
||||||
|
session_db_id INTEGER,
|
||||||
|
created_at_epoch INTEGER NOT NULL,
|
||||||
|
metadata TEXT,
|
||||||
|
FOREIGN KEY (observation_id) REFERENCES observations(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.db.run('CREATE INDEX IF NOT EXISTS idx_feedback_observation ON observation_feedback(observation_id)');
|
||||||
|
this.db.run('CREATE INDEX IF NOT EXISTS idx_feedback_signal ON observation_feedback(signal_type)');
|
||||||
|
|
||||||
|
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
|
||||||
|
logger.debug('DB', 'Created observation_feedback table for usage tracking');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,11 +283,41 @@ export class ChromaSync {
|
|||||||
metadatas: cleanMetadatas
|
metadatas: cleanMetadatas
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('CHROMA_SYNC', 'Batch add failed, continuing with remaining batches', {
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
collection: this.collectionName,
|
// APPROVED OVERRIDE: Duplicate IDs from partial write before timeout/crash.
|
||||||
batchStart: i,
|
// chroma_update_documents only updates *existing* IDs — it silently ignores
|
||||||
batchSize: batch.length
|
// missing ones. So we delete-then-add to guarantee all IDs are written.
|
||||||
}, error as Error);
|
if (errMsg.includes('already exist')) {
|
||||||
|
try {
|
||||||
|
await chromaMcp.callTool('chroma_delete_documents', {
|
||||||
|
collection_name: this.collectionName,
|
||||||
|
ids: batch.map(d => d.id)
|
||||||
|
});
|
||||||
|
await chromaMcp.callTool('chroma_add_documents', {
|
||||||
|
collection_name: this.collectionName,
|
||||||
|
ids: batch.map(d => d.id),
|
||||||
|
documents: batch.map(d => d.document),
|
||||||
|
metadatas: cleanMetadatas
|
||||||
|
});
|
||||||
|
logger.info('CHROMA_SYNC', 'Batch reconciled via delete+add after duplicate conflict', {
|
||||||
|
collection: this.collectionName,
|
||||||
|
batchStart: i,
|
||||||
|
batchSize: batch.length
|
||||||
|
});
|
||||||
|
} catch (reconcileError) {
|
||||||
|
logger.error('CHROMA_SYNC', 'Batch reconcile (delete+add) failed', {
|
||||||
|
collection: this.collectionName,
|
||||||
|
batchStart: i,
|
||||||
|
batchSize: batch.length
|
||||||
|
}, reconcileError as Error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error('CHROMA_SYNC', 'Batch add failed, continuing with remaining batches', {
|
||||||
|
collection: this.collectionName,
|
||||||
|
batchStart: i,
|
||||||
|
batchSize: batch.length
|
||||||
|
}, error as Error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const DEFAULT_STATE_PATH = join(homedir(), '.claude-mem', 'transcript-wat
|
|||||||
|
|
||||||
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
||||||
name: 'codex',
|
name: 'codex',
|
||||||
version: '0.2',
|
version: '0.3',
|
||||||
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
|
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
@@ -46,13 +46,14 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tool-use',
|
name: 'tool-use',
|
||||||
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call'] },
|
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call', 'exec_command'] },
|
||||||
action: 'tool_use',
|
action: 'tool_use',
|
||||||
fields: {
|
fields: {
|
||||||
toolId: 'payload.call_id',
|
toolId: 'payload.call_id',
|
||||||
toolName: {
|
toolName: {
|
||||||
coalesce: [
|
coalesce: [
|
||||||
'payload.name',
|
'payload.name',
|
||||||
|
'payload.type',
|
||||||
{ value: 'web_search' }
|
{ value: 'web_search' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -60,6 +61,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
coalesce: [
|
coalesce: [
|
||||||
'payload.arguments',
|
'payload.arguments',
|
||||||
'payload.input',
|
'payload.input',
|
||||||
|
'payload.command',
|
||||||
'payload.action'
|
'payload.action'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -67,7 +69,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tool-result',
|
name: 'tool-result',
|
||||||
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output'] },
|
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output', 'exec_command_output'] },
|
||||||
action: 'tool_result',
|
action: 'tool_result',
|
||||||
fields: {
|
fields: {
|
||||||
toolId: 'payload.call_id',
|
toolId: 'payload.call_id',
|
||||||
@@ -76,7 +78,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'session-end',
|
name: 'session-end',
|
||||||
match: { path: 'payload.type', equals: 'turn_aborted' },
|
match: { path: 'payload.type', in: ['turn_aborted', 'turn_completed'] },
|
||||||
action: 'session_end'
|
action: 'session_end'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ import {
|
|||||||
updateCursorContextForProject,
|
updateCursorContextForProject,
|
||||||
handleCursorCommand
|
handleCursorCommand
|
||||||
} from './integrations/CursorHooksInstaller.js';
|
} from './integrations/CursorHooksInstaller.js';
|
||||||
|
import {
|
||||||
|
handleGeminiCliCommand
|
||||||
|
} from './integrations/GeminiCliHooksInstaller.js';
|
||||||
|
|
||||||
// Service layer imports
|
// Service layer imports
|
||||||
import { DatabaseManager } from './worker/DatabaseManager.js';
|
import { DatabaseManager } from './worker/DatabaseManager.js';
|
||||||
@@ -128,6 +131,10 @@ import { MemoryRoutes } from './worker/http/routes/MemoryRoutes.js';
|
|||||||
// Process management for zombie cleanup (Issue #737)
|
// Process management for zombie cleanup (Issue #737)
|
||||||
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js';
|
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js';
|
||||||
|
|
||||||
|
// Transcript watcher for external CLI session monitoring
|
||||||
|
import { TranscriptWatcher } from './transcripts/watcher.js';
|
||||||
|
import { loadTranscriptWatchConfig, expandHomePath, DEFAULT_CONFIG_PATH as TRANSCRIPT_CONFIG_PATH } from './transcripts/config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build JSON status output for hook framework communication.
|
* Build JSON status output for hook framework communication.
|
||||||
* This is a pure function extracted for testability.
|
* This is a pure function extracted for testability.
|
||||||
@@ -189,6 +196,9 @@ export class WorkerService {
|
|||||||
// Stale session reaper interval (Issue #1168)
|
// Stale session reaper interval (Issue #1168)
|
||||||
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null;
|
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// Transcript watcher for external CLI sessions (e.g. Codex, Gemini)
|
||||||
|
private transcriptWatcher: TranscriptWatcher | null = null;
|
||||||
|
|
||||||
// AI interaction tracking for health endpoint
|
// AI interaction tracking for health endpoint
|
||||||
private lastAiInteraction: {
|
private lastAiInteraction: {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -421,6 +431,22 @@ export class WorkerService {
|
|||||||
this.resolveInitialization();
|
this.resolveInitialization();
|
||||||
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
|
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
|
||||||
|
|
||||||
|
// Auto-start transcript watchers if configured
|
||||||
|
if (existsSync(TRANSCRIPT_CONFIG_PATH)) {
|
||||||
|
try {
|
||||||
|
const transcriptConfig = loadTranscriptWatchConfig(TRANSCRIPT_CONFIG_PATH);
|
||||||
|
if (transcriptConfig.watches.length > 0) {
|
||||||
|
const transcriptStatePath = expandHomePath(transcriptConfig.stateFile ?? '~/.claude-mem/transcript-watch-state.json');
|
||||||
|
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, transcriptStatePath);
|
||||||
|
await this.transcriptWatcher.start();
|
||||||
|
logger.info('SYSTEM', `Transcript watcher started with ${transcriptConfig.watches.length} watch target(s)`);
|
||||||
|
}
|
||||||
|
} catch (transcriptError) {
|
||||||
|
logger.warn('SYSTEM', 'Failed to start transcript watcher (non-fatal)', {}, transcriptError as Error);
|
||||||
|
// Non-fatal — worker continues without transcript watching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
|
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
|
||||||
if (this.chromaMcpManager) {
|
if (this.chromaMcpManager) {
|
||||||
ChromaSync.backfillAllProjects().then(() => {
|
ChromaSync.backfillAllProjects().then(() => {
|
||||||
@@ -922,6 +948,13 @@ export class WorkerService {
|
|||||||
this.staleSessionReaperInterval = null;
|
this.staleSessionReaperInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop transcript watcher
|
||||||
|
if (this.transcriptWatcher) {
|
||||||
|
this.transcriptWatcher.stop();
|
||||||
|
this.transcriptWatcher = null;
|
||||||
|
logger.info('SYSTEM', 'Transcript watcher stopped');
|
||||||
|
}
|
||||||
|
|
||||||
await performGracefulShutdown({
|
await performGracefulShutdown({
|
||||||
server: this.server.getHttpServer(),
|
server: this.server.getHttpServer(),
|
||||||
sessionManager: this.sessionManager,
|
sessionManager: this.sessionManager,
|
||||||
@@ -1174,14 +1207,21 @@ async function main() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'gemini-cli': {
|
||||||
|
const geminiSubcommand = process.argv[3];
|
||||||
|
const geminiResult = await handleGeminiCliCommand(geminiSubcommand, process.argv.slice(4));
|
||||||
|
process.exit(geminiResult);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'hook': {
|
case 'hook': {
|
||||||
// Validate CLI args first (before any I/O)
|
// Validate CLI args first (before any I/O)
|
||||||
const platform = process.argv[3];
|
const platform = process.argv[3];
|
||||||
const event = process.argv[4];
|
const event = process.argv[4];
|
||||||
if (!platform || !event) {
|
if (!platform || !event) {
|
||||||
console.error('Usage: claude-mem hook <platform> <event>');
|
console.error('Usage: claude-mem hook <platform> <event>');
|
||||||
console.error('Platforms: claude-code, cursor, raw');
|
console.error('Platforms: claude-code, cursor, gemini-cli, raw');
|
||||||
console.error('Events: context, session-init, observation, summarize, session-complete');
|
console.error('Events: context, session-init, observation, summarize, session-complete, user-message');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface ActiveSession {
|
|||||||
// CLAIM-CONFIRM FIX: Track IDs of messages currently being processed
|
// CLAIM-CONFIRM FIX: Track IDs of messages currently being processed
|
||||||
// These IDs will be confirmed (deleted) after successful storage
|
// These IDs will be confirmed (deleted) after successful storage
|
||||||
processingMessageIds: number[];
|
processingMessageIds: number[];
|
||||||
|
// Tier routing: model override per session based on queue complexity
|
||||||
|
modelOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingMessage {
|
export interface PendingMessage {
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export class SDKAgent {
|
|||||||
// Find Claude executable
|
// Find Claude executable
|
||||||
const claudePath = this.findClaudeExecutable();
|
const claudePath = this.findClaudeExecutable();
|
||||||
|
|
||||||
// Get model ID and disallowed tools
|
// Get model ID (tier routing override takes precedence)
|
||||||
const modelId = this.getModelId();
|
const modelId = session.modelOverride || this.getModelId();
|
||||||
// Memory agent is OBSERVER ONLY - no tools allowed
|
// Memory agent is OBSERVER ONLY - no tools allowed
|
||||||
const disallowedTools = [
|
const disallowedTools = [
|
||||||
'Bash', // Prevent infinite loops
|
'Bash', // Prevent infinite loops
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ export async function processAgentResponse(
|
|||||||
const observations = parseObservations(text, session.contentSessionId);
|
const observations = parseObservations(text, session.contentSessionId);
|
||||||
const summary = parseSummary(text, session.sessionDbId);
|
const summary = parseSummary(text, session.sessionDbId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.trim() &&
|
||||||
|
observations.length === 0 &&
|
||||||
|
!summary &&
|
||||||
|
!/<observation>|<summary>|<skip_summary\b/.test(text)
|
||||||
|
) {
|
||||||
|
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
||||||
|
logger.warn('PARSER', `${agentName} returned non-XML response; observation content was discarded`, {
|
||||||
|
sessionId: session.sessionDbId,
|
||||||
|
preview
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Convert nullable fields to empty strings for storeSummary (if summary exists)
|
// Convert nullable fields to empty strings for storeSummary (if summary exists)
|
||||||
const summaryForStore = normalizeSummaryForStorage(summary);
|
const summaryForStore = normalizeSummaryForStorage(summary);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
|||||||
app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this));
|
app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this));
|
||||||
app.get('/api/context/preview', this.handleContextPreview.bind(this));
|
app.get('/api/context/preview', this.handleContextPreview.bind(this));
|
||||||
app.get('/api/context/inject', this.handleContextInject.bind(this));
|
app.get('/api/context/inject', this.handleContextInject.bind(this));
|
||||||
|
app.post('/api/context/semantic', this.handleSemanticContext.bind(this));
|
||||||
|
|
||||||
// Timeline and help endpoints
|
// Timeline and help endpoints
|
||||||
app.get('/api/timeline/by-query', this.handleGetTimelineByQuery.bind(this));
|
app.get('/api/timeline/by-query', this.handleGetTimelineByQuery.bind(this));
|
||||||
@@ -246,6 +247,54 @@ export class SearchRoutes extends BaseRouteHandler {
|
|||||||
res.send(contextText);
|
res.send(contextText);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic context search for per-prompt injection
|
||||||
|
* POST /api/context/semantic { q, project?, limit? }
|
||||||
|
*
|
||||||
|
* Queries Chroma for observations semantically similar to the user's prompt.
|
||||||
|
* Returns compact markdown for injection as additionalContext.
|
||||||
|
*/
|
||||||
|
private handleSemanticContext = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const query = (req.body?.q || req.query.q) as string;
|
||||||
|
const project = (req.body?.project || req.query.project) as string;
|
||||||
|
const limit = Math.min(Math.max(parseInt(String(req.body?.limit || req.query.limit || '5'), 10) || 5, 1), 20);
|
||||||
|
|
||||||
|
if (!query || query.length < 20) {
|
||||||
|
res.json({ context: '', count: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.searchManager.search({
|
||||||
|
query,
|
||||||
|
type: 'observations',
|
||||||
|
project,
|
||||||
|
limit: String(limit),
|
||||||
|
format: 'json'
|
||||||
|
});
|
||||||
|
|
||||||
|
const observations = (result as any)?.observations || [];
|
||||||
|
if (!observations.length) {
|
||||||
|
res.json({ context: '', count: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format as compact markdown for context injection
|
||||||
|
const lines: string[] = ['## Relevant Past Work (semantic match)\n'];
|
||||||
|
for (const obs of observations.slice(0, limit)) {
|
||||||
|
const date = obs.created_at?.slice(0, 10) || '';
|
||||||
|
lines.push(`### ${obs.title || 'Observation'} (${date})`);
|
||||||
|
if (obs.narrative) lines.push(obs.narrative);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ context: lines.join('\n'), count: observations.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('SEARCH', 'Semantic context query failed', {}, error as Error);
|
||||||
|
res.json({ context: '', count: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get timeline by query (search first, then get timeline around best match)
|
* Get timeline by query (search first, then get timeline around best match)
|
||||||
* GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10
|
* GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
|
|
||||||
// Start generator if not running
|
// Start generator if not running
|
||||||
if (!session.generatorPromise) {
|
if (!session.generatorPromise) {
|
||||||
|
// Apply tier routing before starting the generator
|
||||||
|
this.applyTierRouting(session);
|
||||||
this.spawnInProgress.set(sessionDbId, true);
|
this.spawnInProgress.set(sessionDbId, true);
|
||||||
this.startGeneratorWithProvider(session, selectedProvider, source);
|
this.startGeneratorWithProvider(session, selectedProvider, source);
|
||||||
return;
|
return;
|
||||||
@@ -126,6 +128,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
session.abortController = new AbortController();
|
session.abortController = new AbortController();
|
||||||
session.lastGeneratorActivity = Date.now();
|
session.lastGeneratorActivity = Date.now();
|
||||||
// Start a fresh generator
|
// Start a fresh generator
|
||||||
|
this.applyTierRouting(session);
|
||||||
this.spawnInProgress.set(sessionDbId, true);
|
this.spawnInProgress.set(sessionDbId, true);
|
||||||
this.startGeneratorWithProvider(session, selectedProvider, 'stale-recovery');
|
this.startGeneratorWithProvider(session, selectedProvider, 'stale-recovery');
|
||||||
return;
|
return;
|
||||||
@@ -283,6 +286,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
this.crashRecoveryScheduled.delete(sessionDbId);
|
this.crashRecoveryScheduled.delete(sessionDbId);
|
||||||
const stillExists = this.sessionManager.getSession(sessionDbId);
|
const stillExists = this.sessionManager.getSession(sessionDbId);
|
||||||
if (stillExists && !stillExists.generatorPromise) {
|
if (stillExists && !stillExists.generatorPromise) {
|
||||||
|
this.applyTierRouting(stillExists);
|
||||||
this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery');
|
this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery');
|
||||||
}
|
}
|
||||||
}, backoffMs);
|
}, backoffMs);
|
||||||
@@ -321,6 +325,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
|
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
|
||||||
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
|
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
|
||||||
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
|
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
|
||||||
|
app.get('/api/sessions/status', this.handleStatusByClaudeId.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -631,6 +636,39 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
res.json({ status: 'queued' });
|
res.json({ status: 'queued' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session status by contentSessionId (summarize handler polls this)
|
||||||
|
* GET /api/sessions/status?contentSessionId=...
|
||||||
|
*
|
||||||
|
* Returns queue depth so the Stop hook can wait for summary completion.
|
||||||
|
*/
|
||||||
|
private handleStatusByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||||
|
const contentSessionId = req.query.contentSessionId as string;
|
||||||
|
|
||||||
|
if (!contentSessionId) {
|
||||||
|
return this.badRequest(res, 'Missing contentSessionId query parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = this.dbManager.getSessionStore();
|
||||||
|
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||||
|
const session = this.sessionManager.getSession(sessionDbId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.json({ status: 'not_found', queueLength: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||||
|
const queueLength = pendingStore.getPendingCount(sessionDbId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'active',
|
||||||
|
sessionDbId,
|
||||||
|
queueLength,
|
||||||
|
uptime: Date.now() - session.startTime
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete session by contentSessionId (session-complete hook uses this)
|
* Complete session by contentSessionId (session-complete hook uses this)
|
||||||
* POST /api/sessions/complete
|
* POST /api/sessions/complete
|
||||||
@@ -669,6 +707,8 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Complete the session (removes from active sessions map)
|
// Complete the session (removes from active sessions map)
|
||||||
|
// Note: The Stop hook (summarize handler) waits for pending work before calling
|
||||||
|
// this endpoint. No polling here — that's the hook's responsibility.
|
||||||
await this.completionHandler.completeByDbId(sessionDbId);
|
await this.completionHandler.completeByDbId(sessionDbId);
|
||||||
|
|
||||||
logger.info('SESSION', 'Session completed via API', {
|
logger.info('SESSION', 'Session completed via API', {
|
||||||
@@ -777,4 +817,60 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
contextInjected
|
contextInjected
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Simple tool names that produce low-complexity observations
|
||||||
|
private static readonly SIMPLE_TOOLS = new Set([
|
||||||
|
'Read', 'Glob', 'Grep', 'LS', 'ListMcpResourcesTool'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply tier routing: select model based on pending queue complexity.
|
||||||
|
* - Summarize in queue → summary model (e.g., Opus)
|
||||||
|
* - All simple tools → simple model (e.g., Haiku)
|
||||||
|
* - Otherwise → default model (no override)
|
||||||
|
*/
|
||||||
|
private applyTierRouting(session: NonNullable<ReturnType<typeof this.sessionManager.getSession>>): void {
|
||||||
|
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||||
|
if (settings.CLAUDE_MEM_TIER_ROUTING_ENABLED === 'false') {
|
||||||
|
session.modelOverride = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stale override before re-evaluating — prevents previous tier
|
||||||
|
// from persisting when queue composition changes between spawns.
|
||||||
|
session.modelOverride = undefined;
|
||||||
|
|
||||||
|
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||||
|
const pending = pendingStore.peekPendingTypes(session.sessionDbId);
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
session.modelOverride = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSummarize = pending.some(m => m.message_type === 'summarize');
|
||||||
|
const allSimple = pending.every(m =>
|
||||||
|
m.message_type === 'observation' && m.tool_name && SessionRoutes.SIMPLE_TOOLS.has(m.tool_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasSummarize) {
|
||||||
|
const summaryModel = settings.CLAUDE_MEM_TIER_SUMMARY_MODEL;
|
||||||
|
if (summaryModel) {
|
||||||
|
session.modelOverride = summaryModel;
|
||||||
|
logger.debug('SESSION', `Tier routing: summary model`, {
|
||||||
|
sessionId: session.sessionDbId, model: summaryModel
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (allSimple) {
|
||||||
|
const simpleModel = settings.CLAUDE_MEM_TIER_SIMPLE_MODEL;
|
||||||
|
if (simpleModel) {
|
||||||
|
session.modelOverride = simpleModel;
|
||||||
|
logger.debug('SESSION', `Tier routing: simple model`, {
|
||||||
|
sessionId: session.sessionDbId, model: simpleModel
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.modelOverride = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,30 @@ export class SessionCompletionHandler {
|
|||||||
* Used by DELETE /api/sessions/:id and POST /api/sessions/:id/complete
|
* Used by DELETE /api/sessions/:id and POST /api/sessions/:id/complete
|
||||||
*/
|
*/
|
||||||
async completeByDbId(sessionDbId: number): Promise<void> {
|
async completeByDbId(sessionDbId: number): Promise<void> {
|
||||||
// Delete from session manager (aborts SDK agent)
|
// Delete from session manager (aborts SDK agent via SIGTERM)
|
||||||
await this.sessionManager.deleteSession(sessionDbId);
|
await this.sessionManager.deleteSession(sessionDbId);
|
||||||
|
|
||||||
|
// Drain orphaned pending messages left by SIGTERM.
|
||||||
|
// When deleteSession() aborts the generator, pending messages in the queue
|
||||||
|
// are never processed. Without drain, they stay in 'pending' status forever
|
||||||
|
// since no future generator will pick them up for a completed session.
|
||||||
|
// Note: this is best-effort — if a generator outlives the 30s SIGTERM timeout
|
||||||
|
// (SessionManager.deleteSession), it may enqueue messages after this drain.
|
||||||
|
// In practice this race is rare (zero orphans over 23 days, 3400+ observations).
|
||||||
|
try {
|
||||||
|
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||||
|
const drainedCount = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
|
||||||
|
if (drainedCount > 0) {
|
||||||
|
logger.warn('SESSION', `Drained ${drainedCount} orphaned pending messages on session completion`, {
|
||||||
|
sessionId: sessionDbId, drainedCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('SESSION', 'Failed to drain pending queue on session completion', {
|
||||||
|
sessionId: sessionDbId, error: e instanceof Error ? e.message : String(e)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast session completed event
|
// Broadcast session completed event
|
||||||
this.eventBroadcaster.broadcastSessionCompleted(sessionDbId);
|
this.eventBroadcaster.broadcastSessionCompleted(sessionDbId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ export interface SettingsDefaults {
|
|||||||
// Exclusion Settings
|
// Exclusion Settings
|
||||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
||||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||||
|
// Semantic Context Injection (per-prompt via Chroma)
|
||||||
|
CLAUDE_MEM_SEMANTIC_INJECT: string; // 'true' | 'false' - inject relevant observations on each prompt
|
||||||
|
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: string; // Max observations to inject per prompt
|
||||||
|
// Tier Routing (model selection by queue complexity)
|
||||||
|
CLAUDE_MEM_TIER_ROUTING_ENABLED: string; // 'true' | 'false' - enable model tier routing
|
||||||
|
CLAUDE_MEM_TIER_SIMPLE_MODEL: string; // Tier alias or model ID for simple tool observations (Read, Glob, Grep)
|
||||||
|
CLAUDE_MEM_TIER_SUMMARY_MODEL: string; // Tier alias or model ID for session summaries
|
||||||
// Chroma Vector Database Configuration
|
// Chroma Vector Database Configuration
|
||||||
CLAUDE_MEM_CHROMA_ENABLED: string; // 'true' | 'false' - set to 'false' for SQLite-only mode
|
CLAUDE_MEM_CHROMA_ENABLED: string; // 'true' | 'false' - set to 'false' for SQLite-only mode
|
||||||
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
|
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
|
||||||
@@ -113,6 +120,13 @@ export class SettingsDefaultsManager {
|
|||||||
// Exclusion Settings
|
// Exclusion Settings
|
||||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
||||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
|
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||||
|
// Semantic Context Injection (per-prompt via Chroma vector search)
|
||||||
|
CLAUDE_MEM_SEMANTIC_INJECT: 'true', // Inject relevant past observations on every UserPromptSubmit
|
||||||
|
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: '5', // Top-N most relevant observations to inject per prompt
|
||||||
|
// Tier Routing (model selection by queue complexity)
|
||||||
|
CLAUDE_MEM_TIER_ROUTING_ENABLED: 'true', // Route observations to models by complexity
|
||||||
|
CLAUDE_MEM_TIER_SIMPLE_MODEL: 'haiku', // Portable tier alias — works across Direct API, Bedrock, Vertex, Azure (see #1463)
|
||||||
|
CLAUDE_MEM_TIER_SUMMARY_MODEL: '', // Empty = use default model for summaries
|
||||||
// Chroma Vector Database Configuration
|
// Chroma Vector Database Configuration
|
||||||
CLAUDE_MEM_CHROMA_ENABLED: 'true', // Set to 'false' to disable Chroma and use SQLite-only search
|
CLAUDE_MEM_CHROMA_ENABLED: 'true', // Set to 'false' to disable Chroma and use SQLite-only search
|
||||||
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' uses persistent chroma-mcp via uvx, 'remote' connects to existing server
|
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' uses persistent chroma-mcp via uvx, 'remote' connects to existing server
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Shared context injection utilities for claude-mem.
|
||||||
|
*
|
||||||
|
* Provides tag constants and a function to inject or update a
|
||||||
|
* <claude-mem-context> section in any markdown file. Used by
|
||||||
|
* MCP integrations and OpenCode installer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tag Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CONTEXT_TAG_OPEN = '<claude-mem-context>';
|
||||||
|
export const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context Injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject or update a <claude-mem-context> section in a markdown file.
|
||||||
|
* Creates the file if it doesn't exist. Preserves content outside the tags.
|
||||||
|
*
|
||||||
|
* @param filePath - Absolute path to the target markdown file.
|
||||||
|
* @param contextContent - The content to place between the context tags.
|
||||||
|
* @param headerLine - Optional first line written when creating a new file
|
||||||
|
* (e.g. `"# Claude-Mem Memory Context"` for AGENTS.md).
|
||||||
|
*/
|
||||||
|
export function injectContextIntoMarkdownFile(
|
||||||
|
filePath: string,
|
||||||
|
contextContent: string,
|
||||||
|
headerLine?: string,
|
||||||
|
): void {
|
||||||
|
const parentDirectory = path.dirname(filePath);
|
||||||
|
mkdirSync(parentDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
|
||||||
|
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
let existingContent = readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
|
||||||
|
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
|
||||||
|
|
||||||
|
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
|
||||||
|
// Replace existing section
|
||||||
|
existingContent =
|
||||||
|
existingContent.slice(0, tagStartIndex) +
|
||||||
|
wrappedContent +
|
||||||
|
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
|
||||||
|
} else {
|
||||||
|
// Append section
|
||||||
|
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(filePath, existingContent, 'utf-8');
|
||||||
|
} else {
|
||||||
|
// Create new file
|
||||||
|
if (headerLine) {
|
||||||
|
writeFileSync(filePath, `${headerLine}\n\n${wrappedContent}\n`, 'utf-8');
|
||||||
|
} else {
|
||||||
|
writeFileSync(filePath, wrappedContent + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Shared JSON file utilities for claude-mem.
|
||||||
|
*
|
||||||
|
* Provides safe read/write helpers used across the CLI and services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a JSON file safely, returning a default value if the file
|
||||||
|
* does not exist. Throws on corrupt JSON to prevent silent data loss
|
||||||
|
* when callers merge and write back.
|
||||||
|
*
|
||||||
|
* @param filePath - Absolute path to the JSON file.
|
||||||
|
* @param defaultValue - Value returned when the file is missing.
|
||||||
|
* @returns The parsed JSON content, or `defaultValue` when file is missing.
|
||||||
|
* @throws {Error} When the file exists but contains invalid JSON.
|
||||||
|
*/
|
||||||
|
export function readJsonSafe<T>(filePath: string, defaultValue: T): T {
|
||||||
|
if (!existsSync(filePath)) return defaultValue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Corrupt JSON file, refusing to overwrite: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import {
|
||||||
|
injectContextIntoMarkdownFile,
|
||||||
|
CONTEXT_TAG_OPEN,
|
||||||
|
CONTEXT_TAG_CLOSE,
|
||||||
|
} from '../src/utils/context-injection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the shared context injection utility.
|
||||||
|
*
|
||||||
|
* injectContextIntoMarkdownFile is used by MCP integrations and OpenCode
|
||||||
|
* installer to inject or update a <claude-mem-context> section in markdown files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Context Injection', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `context-injection-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tag constants', () => {
|
||||||
|
it('exports correct open and close tags', () => {
|
||||||
|
expect(CONTEXT_TAG_OPEN).toBe('<claude-mem-context>');
|
||||||
|
expect(CONTEXT_TAG_CLOSE).toBe('</claude-mem-context>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inject into new file', () => {
|
||||||
|
it('creates a new file with context tags when file does not exist', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'Hello world');
|
||||||
|
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain(CONTEXT_TAG_OPEN);
|
||||||
|
expect(content).toContain('Hello world');
|
||||||
|
expect(content).toContain(CONTEXT_TAG_CLOSE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates parent directories if they do not exist', () => {
|
||||||
|
const filePath = join(tempDir, 'nested', 'deep', 'CLAUDE.md');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'test content');
|
||||||
|
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes content wrapped in context tags', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
const contextContent = '# Recent Activity\n\nSome memory data here.';
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, contextContent);
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
const expected = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}\n`;
|
||||||
|
expect(content).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headerLine support', () => {
|
||||||
|
it('prepends headerLine when creating a new file', () => {
|
||||||
|
const filePath = join(tempDir, 'AGENTS.md');
|
||||||
|
const headerLine = '# Claude-Mem Memory Context';
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'context data', headerLine);
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content.startsWith(headerLine)).toBe(true);
|
||||||
|
expect(content).toContain(CONTEXT_TAG_OPEN);
|
||||||
|
expect(content).toContain('context data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a blank line between headerLine and context tags', () => {
|
||||||
|
const filePath = join(tempDir, 'AGENTS.md');
|
||||||
|
const headerLine = '# My Header';
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'data', headerLine);
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toBe(`${headerLine}\n\n${CONTEXT_TAG_OPEN}\ndata\n${CONTEXT_TAG_CLOSE}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use headerLine when file already exists', () => {
|
||||||
|
const filePath = join(tempDir, 'AGENTS.md');
|
||||||
|
writeFileSync(filePath, '# Existing Content\n\nSome stuff.\n');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'new context', '# Should Not Appear');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('# Existing Content');
|
||||||
|
expect(content).not.toContain('# Should Not Appear');
|
||||||
|
expect(content).toContain('new context');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('replace existing context section', () => {
|
||||||
|
it('replaces content between existing context tags', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
const initialContent = [
|
||||||
|
'# Project Instructions',
|
||||||
|
'',
|
||||||
|
`${CONTEXT_TAG_OPEN}`,
|
||||||
|
'Old context data',
|
||||||
|
`${CONTEXT_TAG_CLOSE}`,
|
||||||
|
'',
|
||||||
|
'## Other stuff',
|
||||||
|
].join('\n');
|
||||||
|
writeFileSync(filePath, initialContent);
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'New context data');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('New context data');
|
||||||
|
expect(content).not.toContain('Old context data');
|
||||||
|
expect(content).toContain('# Project Instructions');
|
||||||
|
expect(content).toContain('## Other stuff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves content before and after the context section', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
const before = '# Header\n\nSome instructions.\n\n';
|
||||||
|
const after = '\n\n## Footer\n\nMore content.\n';
|
||||||
|
const initialContent = `${before}${CONTEXT_TAG_OPEN}\nold\n${CONTEXT_TAG_CLOSE}${after}`;
|
||||||
|
writeFileSync(filePath, initialContent);
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'replaced');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('# Header');
|
||||||
|
expect(content).toContain('Some instructions.');
|
||||||
|
expect(content).toContain('## Footer');
|
||||||
|
expect(content).toContain('More content.');
|
||||||
|
expect(content).toContain('replaced');
|
||||||
|
expect(content).not.toContain('old');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('append to existing file', () => {
|
||||||
|
it('appends context section to file without existing tags', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
writeFileSync(filePath, '# My Project\n\nInstructions here.\n');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'appended context');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('# My Project');
|
||||||
|
expect(content).toContain('Instructions here.');
|
||||||
|
expect(content).toContain(CONTEXT_TAG_OPEN);
|
||||||
|
expect(content).toContain('appended context');
|
||||||
|
expect(content).toContain(CONTEXT_TAG_CLOSE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates appended section with a blank line', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
writeFileSync(filePath, '# Header');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'data');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
// Should have double newline before the tag
|
||||||
|
expect(content).toContain(`# Header\n\n${CONTEXT_TAG_OPEN}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims trailing whitespace before appending', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
writeFileSync(filePath, '# Header\n\n\n \n');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'data');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
// Should not have excessive whitespace before the tag
|
||||||
|
expect(content).toContain(`# Header\n\n${CONTEXT_TAG_OPEN}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('idempotency', () => {
|
||||||
|
it('produces same result when called twice with same content', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'stable content');
|
||||||
|
const firstWrite = readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'stable content');
|
||||||
|
const secondWrite = readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
expect(secondWrite).toBe(firstWrite);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates content when called with different data', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'version 1');
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'version 2');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('version 2');
|
||||||
|
expect(content).not.toContain('version 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||||
|
import net from 'net';
|
||||||
import {
|
import {
|
||||||
isPortInUse,
|
isPortInUse,
|
||||||
waitForHealth,
|
waitForHealth,
|
||||||
@@ -15,45 +16,73 @@ describe('HealthMonitor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('isPortInUse', () => {
|
describe('isPortInUse', () => {
|
||||||
it('should return true for occupied port (health check succeeds)', async () => {
|
// Note: Since we are on Linux (as per session_context), isPortInUse uses 'net'
|
||||||
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
|
// instead of 'fetch'. We need to mock 'net.createServer().listen()'
|
||||||
|
|
||||||
|
it('should return true for occupied port (EADDRINUSE)', async () => {
|
||||||
|
// Create a specific mock for this test
|
||||||
|
const createServerMock = mock(() => ({
|
||||||
|
once: mock((event: string, cb: Function) => {
|
||||||
|
if (event === 'error') {
|
||||||
|
// Trigger EADDRINUSE immediately
|
||||||
|
setTimeout(() => cb({ code: 'EADDRINUSE' }), 0);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
listen: mock(() => {})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const spy = spyOn(net, 'createServer').mockImplementation(createServerMock as any);
|
||||||
|
|
||||||
const result = await isPortInUse(37777);
|
const result = await isPortInUse(37777);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(global.fetch).toHaveBeenCalledWith('http://127.0.0.1:37777/api/health');
|
expect(net.createServer).toHaveBeenCalled();
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for free port (connection refused)', async () => {
|
it('should return false for free port (listening succeeds)', async () => {
|
||||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
const closeMock = mock((cb: Function) => cb());
|
||||||
|
const createServerMock = mock(() => ({
|
||||||
|
once: mock((event: string, cb: Function) => {
|
||||||
|
if (event === 'listening') {
|
||||||
|
// Trigger listening success
|
||||||
|
setTimeout(() => cb(), 0);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
listen: mock(() => {}),
|
||||||
|
close: closeMock
|
||||||
|
}));
|
||||||
|
|
||||||
|
const spy = spyOn(net, 'createServer').mockImplementation(createServerMock as any);
|
||||||
|
|
||||||
const result = await isPortInUse(39999);
|
const result = await isPortInUse(39999);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
|
expect(net.createServer).toHaveBeenCalled();
|
||||||
|
expect(closeMock).toHaveBeenCalled();
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when health check returns non-ok', async () => {
|
it('should return false for other socket errors', async () => {
|
||||||
global.fetch = mock(() => Promise.resolve({ ok: false, status: 503 } as Response));
|
const createServerMock = mock(() => ({
|
||||||
|
once: mock((event: string, cb: Function) => {
|
||||||
const result = await isPortInUse(37777);
|
if (event === 'error') {
|
||||||
|
// Trigger other error (e.g., EACCES)
|
||||||
expect(result).toBe(false);
|
setTimeout(() => cb({ code: 'EACCES' }), 0);
|
||||||
});
|
}
|
||||||
|
}),
|
||||||
it('should return false on network timeout', async () => {
|
listen: mock(() => {})
|
||||||
global.fetch = mock(() => Promise.reject(new Error('ETIMEDOUT')));
|
}));
|
||||||
|
|
||||||
const result = await isPortInUse(37777);
|
const spy = spyOn(net, 'createServer').mockImplementation(createServerMock as any);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false on fetch failed error', async () => {
|
|
||||||
global.fetch = mock(() => Promise.reject(new Error('fetch failed')));
|
|
||||||
|
|
||||||
const result = await isPortInUse(37777);
|
const result = await isPortInUse(37777);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,54 +232,80 @@ describe('HealthMonitor', () => {
|
|||||||
|
|
||||||
describe('waitForPortFree', () => {
|
describe('waitForPortFree', () => {
|
||||||
it('should return true immediately when port is already free', async () => {
|
it('should return true immediately when port is already free', async () => {
|
||||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
const createServerMock = mock(() => ({
|
||||||
|
once: mock((event: string, cb: Function) => {
|
||||||
|
if (event === 'listening') setTimeout(() => cb(), 0);
|
||||||
|
}),
|
||||||
|
listen: mock(() => {}),
|
||||||
|
close: mock((cb: Function) => cb())
|
||||||
|
}));
|
||||||
|
const spy = spyOn(net, 'createServer').mockImplementation(createServerMock as any);
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const result = await waitForPortFree(39999, 5000);
|
const result = await waitForPortFree(39999, 5000);
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
// Should return quickly
|
|
||||||
expect(elapsed).toBeLessThan(1000);
|
expect(elapsed).toBeLessThan(1000);
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should timeout when port remains occupied', async () => {
|
it('should timeout when port remains occupied', async () => {
|
||||||
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
|
const createServerMock = mock(() => ({
|
||||||
|
once: mock((event: string, cb: Function) => {
|
||||||
|
if (event === 'error') setTimeout(() => cb({ code: 'EADDRINUSE' }), 0);
|
||||||
|
}),
|
||||||
|
listen: mock(() => {})
|
||||||
|
}));
|
||||||
|
const spy = spyOn(net, 'createServer').mockImplementation(createServerMock as any);
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const result = await waitForPortFree(37777, 1500);
|
const result = await waitForPortFree(37777, 1500);
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
// Should take close to timeout duration
|
|
||||||
expect(elapsed).toBeGreaterThanOrEqual(1400);
|
expect(elapsed).toBeGreaterThanOrEqual(1400);
|
||||||
expect(elapsed).toBeLessThan(2500);
|
expect(elapsed).toBeLessThan(2500);
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should succeed when port becomes free', async () => {
|
it('should succeed when port becomes free', async () => {
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
global.fetch = mock(() => {
|
const spy = spyOn(net, 'createServer').mockImplementation(() => ({
|
||||||
callCount++;
|
once: mock((event: string, cb: Function) => {
|
||||||
// Port occupied for first 2 checks, then free
|
callCount++;
|
||||||
if (callCount < 3) {
|
// Port occupied for first 2 checks, then free
|
||||||
return Promise.resolve({ ok: true } as Response);
|
if (callCount < 3) {
|
||||||
}
|
if (event === 'error') setTimeout(() => cb({ code: 'EADDRINUSE' }), 0);
|
||||||
return Promise.reject(new Error('ECONNREFUSED'));
|
} else {
|
||||||
});
|
if (event === 'listening') setTimeout(() => cb(), 0);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
listen: mock(() => {}),
|
||||||
|
close: mock((cb: Function) => cb())
|
||||||
|
} as any));
|
||||||
|
|
||||||
const result = await waitForPortFree(37777, 5000);
|
const result = await waitForPortFree(37777, 5000);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(callCount).toBeGreaterThanOrEqual(3);
|
expect(callCount).toBeGreaterThanOrEqual(3);
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default timeout when not specified', async () => {
|
it('should use default timeout when not specified', async () => {
|
||||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
const createServerMock = mock(() => ({
|
||||||
|
once: mock((event: string, cb: Function) => {
|
||||||
|
if (event === 'listening') setTimeout(() => cb(), 0);
|
||||||
|
}),
|
||||||
|
listen: mock(() => {}),
|
||||||
|
close: mock((cb: Function) => cb())
|
||||||
|
}));
|
||||||
|
const spy = spyOn(net, 'createServer').mockImplementation(createServerMock as any);
|
||||||
|
|
||||||
// Just verify it doesn't throw and returns quickly
|
|
||||||
const result = await waitForPortFree(39999);
|
const result = await waitForPortFree(39999);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the non-TTY detection in the install command.
|
||||||
|
*
|
||||||
|
* The install command (src/npx-cli/commands/install.ts) has non-interactive
|
||||||
|
* fallbacks so it works in CI/CD, Docker, and piped environments where
|
||||||
|
* process.stdin.isTTY is undefined.
|
||||||
|
*
|
||||||
|
* Since isInteractive, runTasks, and log are not exported, we verify
|
||||||
|
* their presence and correctness via source inspection. This is a valid
|
||||||
|
* approach for testing private module-level constructs that can't be
|
||||||
|
* imported directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const installSourcePath = join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'src',
|
||||||
|
'npx-cli',
|
||||||
|
'commands',
|
||||||
|
'install.ts',
|
||||||
|
);
|
||||||
|
const installSource = readFileSync(installSourcePath, 'utf-8');
|
||||||
|
|
||||||
|
describe('Install Non-TTY Support', () => {
|
||||||
|
describe('isInteractive flag', () => {
|
||||||
|
it('defines isInteractive based on process.stdin.isTTY', () => {
|
||||||
|
expect(installSource).toContain('const isInteractive = process.stdin.isTTY === true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses strict equality (===) not truthy check for isTTY', () => {
|
||||||
|
// Ensures undefined isTTY is treated as false, not just falsy
|
||||||
|
const match = installSource.match(/const isInteractive = process\.stdin\.isTTY === true/);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTasks helper', () => {
|
||||||
|
it('defines a runTasks function', () => {
|
||||||
|
expect(installSource).toContain('async function runTasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has interactive branch using p.tasks', () => {
|
||||||
|
expect(installSource).toContain('await p.tasks(tasks)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has non-interactive fallback using console.log', () => {
|
||||||
|
// In non-TTY mode, tasks iterate and log output directly
|
||||||
|
expect(installSource).toContain('console.log(` ${msg}`)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('branches on isInteractive', () => {
|
||||||
|
expect(installSource).toContain('if (isInteractive)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('log wrapper', () => {
|
||||||
|
it('defines log.info that falls back to console.log', () => {
|
||||||
|
expect(installSource).toContain('info: (msg: string) =>');
|
||||||
|
// Should have console.log fallback
|
||||||
|
expect(installSource).toMatch(/info:.*console\.log/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines log.success that falls back to console.log', () => {
|
||||||
|
expect(installSource).toContain('success: (msg: string) =>');
|
||||||
|
expect(installSource).toMatch(/success:.*console\.log/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines log.warn that falls back to console.warn', () => {
|
||||||
|
expect(installSource).toContain('warn: (msg: string) =>');
|
||||||
|
expect(installSource).toMatch(/warn:.*console\.warn/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines log.error that falls back to console.error', () => {
|
||||||
|
expect(installSource).toContain('error: (msg: string) =>');
|
||||||
|
expect(installSource).toMatch(/error:.*console\.error/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('non-interactive install path', () => {
|
||||||
|
it('defaults to claude-code when not interactive and no IDE specified', () => {
|
||||||
|
// The non-interactive path should have a fallback
|
||||||
|
expect(installSource).toContain("selectedIDEs = ['claude-code']");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses console.log for intro in non-interactive mode', () => {
|
||||||
|
expect(installSource).toContain("console.log('claude-mem install')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses console.log for note/summary in non-interactive mode', () => {
|
||||||
|
expect(installSource).toContain("console.log(`\\n ${installStatus}`)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskDescriptor interface', () => {
|
||||||
|
it('defines a task interface with title and task function', () => {
|
||||||
|
expect(installSource).toContain('interface TaskDescriptor');
|
||||||
|
expect(installSource).toContain('title: string');
|
||||||
|
expect(installSource).toContain('task: (message: (msg: string) => void) => Promise<string>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InstallOptions interface', () => {
|
||||||
|
it('exports InstallOptions with optional ide field', () => {
|
||||||
|
expect(installSource).toContain('export interface InstallOptions');
|
||||||
|
expect(installSource).toContain('ide?: string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { readJsonSafe } from '../src/utils/json-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the shared JSON file utilities.
|
||||||
|
*
|
||||||
|
* readJsonSafe is used across the CLI and services to safely read JSON
|
||||||
|
* files with fallback to defaults when files are missing or corrupt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('JSON Utils', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `json-utils-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readJsonSafe', () => {
|
||||||
|
it('returns default value when file does not exist', () => {
|
||||||
|
const nonExistentPath = join(tempDir, 'does-not-exist.json');
|
||||||
|
|
||||||
|
const result = readJsonSafe(nonExistentPath, { fallback: true });
|
||||||
|
|
||||||
|
expect(result).toEqual({ fallback: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns parsed content for valid JSON file', () => {
|
||||||
|
const filePath = join(tempDir, 'valid.json');
|
||||||
|
const data = { name: 'test', count: 42, nested: { key: 'value' } };
|
||||||
|
writeFileSync(filePath, JSON.stringify(data));
|
||||||
|
|
||||||
|
const result = readJsonSafe(filePath, {});
|
||||||
|
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on corrupt JSON file to prevent data loss', () => {
|
||||||
|
const filePath = join(tempDir, 'corrupt.json');
|
||||||
|
writeFileSync(filePath, 'this is not valid json {{{');
|
||||||
|
|
||||||
|
expect(() => readJsonSafe(filePath, { recovered: true })).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on empty file to prevent data loss', () => {
|
||||||
|
const filePath = join(tempDir, 'empty.json');
|
||||||
|
writeFileSync(filePath, '');
|
||||||
|
|
||||||
|
expect(() => readJsonSafe(filePath, [])).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with array default values', () => {
|
||||||
|
const nonExistentPath = join(tempDir, 'missing.json');
|
||||||
|
|
||||||
|
const result = readJsonSafe<string[]>(nonExistentPath, ['a', 'b']);
|
||||||
|
|
||||||
|
expect(result).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with string default values', () => {
|
||||||
|
const nonExistentPath = join(tempDir, 'missing.json');
|
||||||
|
|
||||||
|
const result = readJsonSafe<string>(nonExistentPath, 'default');
|
||||||
|
|
||||||
|
expect(result).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with number default values', () => {
|
||||||
|
const nonExistentPath = join(tempDir, 'missing.json');
|
||||||
|
|
||||||
|
const result = readJsonSafe<number>(nonExistentPath, 0);
|
||||||
|
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads JSON arrays correctly', () => {
|
||||||
|
const filePath = join(tempDir, 'array.json');
|
||||||
|
writeFileSync(filePath, JSON.stringify([1, 2, 3]));
|
||||||
|
|
||||||
|
const result = readJsonSafe<number[]>(filePath, []);
|
||||||
|
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads deeply nested JSON correctly', () => {
|
||||||
|
const filePath = join(tempDir, 'nested.json');
|
||||||
|
const deepData = {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {
|
||||||
|
value: 'deep',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(filePath, JSON.stringify(deepData));
|
||||||
|
|
||||||
|
const result = readJsonSafe<typeof deepData>(filePath, { level1: { level2: { level3: { value: '' } } } });
|
||||||
|
|
||||||
|
expect(result.level1.level2.level3.value).toBe('deep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles JSON with trailing newline', () => {
|
||||||
|
const filePath = join(tempDir, 'trailing-newline.json');
|
||||||
|
writeFileSync(filePath, JSON.stringify({ ok: true }) + '\n');
|
||||||
|
|
||||||
|
const result = readJsonSafe(filePath, {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the MCP integration factory utilities.
|
||||||
|
*
|
||||||
|
* Because McpIntegrations.ts uses `findMcpServerPath()` which checks specific
|
||||||
|
* filesystem paths, and the factory functions are not individually exported,
|
||||||
|
* we test the underlying helpers indirectly by exercising writeMcpJsonConfig
|
||||||
|
* and buildMcpServerEntry behavior through the readJsonSafe + JSON file writing
|
||||||
|
* patterns they use.
|
||||||
|
*
|
||||||
|
* We also verify the key behavioral contract: MCP entries use process.execPath.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readJsonSafe } from '../src/utils/json-utils';
|
||||||
|
import { injectContextIntoMarkdownFile, CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE } from '../src/utils/context-injection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reimplements the core logic of buildMcpServerEntry and writeMcpJsonConfig
|
||||||
|
* from McpIntegrations.ts for testability, since those functions are not exported.
|
||||||
|
* The tests verify the contract these functions must uphold.
|
||||||
|
*/
|
||||||
|
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [mcpServerPath],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMcpJsonConfig(
|
||||||
|
configFilePath: string,
|
||||||
|
mcpServerPath: string,
|
||||||
|
serversKeyName: string = 'mcpServers',
|
||||||
|
): void {
|
||||||
|
const parentDirectory = join(configFilePath, '..');
|
||||||
|
mkdirSync(parentDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const existingConfig = readJsonSafe<Record<string, any>>(configFilePath, {});
|
||||||
|
|
||||||
|
if (!existingConfig[serversKeyName]) {
|
||||||
|
existingConfig[serversKeyName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
existingConfig[serversKeyName]['claude-mem'] = buildMcpServerEntry(mcpServerPath);
|
||||||
|
|
||||||
|
writeFileSync(configFilePath, JSON.stringify(existingConfig, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MCP Integrations', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `mcp-integrations-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMcpServerEntry', () => {
|
||||||
|
it('uses process.execPath as the command, not "node"', () => {
|
||||||
|
const entry = buildMcpServerEntry('/path/to/mcp-server.cjs');
|
||||||
|
|
||||||
|
expect(entry.command).toBe(process.execPath);
|
||||||
|
expect(entry.command).not.toBe('node');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the mcp server path as the sole argument', () => {
|
||||||
|
const serverPath = '/usr/local/lib/mcp-server.cjs';
|
||||||
|
const entry = buildMcpServerEntry(serverPath);
|
||||||
|
|
||||||
|
expect(entry.args).toEqual([serverPath]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles paths with spaces', () => {
|
||||||
|
const serverPath = '/path/to/my project/mcp-server.cjs';
|
||||||
|
const entry = buildMcpServerEntry(serverPath);
|
||||||
|
|
||||||
|
expect(entry.args).toEqual([serverPath]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeMcpJsonConfig', () => {
|
||||||
|
it('creates config file if it does not exist', () => {
|
||||||
|
const configPath = join(tempDir, '.config', 'ide', 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
expect(existsSync(configPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates parent directories if they do not exist', () => {
|
||||||
|
const configPath = join(tempDir, 'deep', 'nested', 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
expect(existsSync(join(tempDir, 'deep', 'nested'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes valid JSON with claude-mem entry', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
const config = JSON.parse(content);
|
||||||
|
expect(config.mcpServers).toBeDefined();
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
expect(config.mcpServers['claude-mem'].command).toBe(process.execPath);
|
||||||
|
expect(config.mcpServers['claude-mem'].args).toEqual(['/path/to/mcp.cjs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom serversKeyName when provided', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs', 'servers');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.servers).toBeDefined();
|
||||||
|
expect(config.servers['claude-mem']).toBeDefined();
|
||||||
|
expect(config.mcpServers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing servers when adding claude-mem', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
const existingConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
'other-tool': {
|
||||||
|
command: 'python',
|
||||||
|
args: ['/path/to/other.py'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(configPath, JSON.stringify(existingConfig));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.mcpServers['other-tool']).toBeDefined();
|
||||||
|
expect(config.mcpServers['other-tool'].command).toBe('python');
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-server keys in existing config', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
const existingConfig = {
|
||||||
|
version: 2,
|
||||||
|
settings: { theme: 'dark' },
|
||||||
|
mcpServers: {},
|
||||||
|
};
|
||||||
|
writeFileSync(configPath, JSON.stringify(existingConfig));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.version).toBe(2);
|
||||||
|
expect(config.settings).toEqual({ theme: 'dark' });
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('idempotency', () => {
|
||||||
|
it('running install twice does not create duplicate entries', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
const serverKeys = Object.keys(config.mcpServers);
|
||||||
|
const claudeMemEntries = serverKeys.filter((k) => k === 'claude-mem');
|
||||||
|
expect(claudeMemEntries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the server path on re-install', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/old/path/mcp.cjs');
|
||||||
|
writeMcpJsonConfig(configPath, '/new/path/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.mcpServers['claude-mem'].args).toEqual(['/new/path/mcp.cjs']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('corrupt file recovery', () => {
|
||||||
|
it('throws on corrupt JSON to prevent data loss', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, 'not valid json {{{{');
|
||||||
|
|
||||||
|
expect(() => writeMcpJsonConfig(configPath, '/path/to/mcp.cjs')).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
|
||||||
|
// Original file should be untouched
|
||||||
|
expect(readFileSync(configPath, 'utf-8')).toBe('not valid json {{{{');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on empty file to prevent data loss', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, '');
|
||||||
|
|
||||||
|
expect(() => writeMcpJsonConfig(configPath, '/path/to/mcp.cjs')).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on file with only whitespace', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, ' \n\n ');
|
||||||
|
|
||||||
|
expect(() => writeMcpJsonConfig(configPath, '/path/to/mcp.cjs')).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('merge with existing config', () => {
|
||||||
|
it('preserves other servers in mcpServers key', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
const existingConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
'server-a': { command: 'ruby', args: ['/a.rb'] },
|
||||||
|
'server-b': { command: 'node', args: ['/b.js'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(configPath, JSON.stringify(existingConfig));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(Object.keys(config.mcpServers)).toHaveLength(3);
|
||||||
|
expect(config.mcpServers['server-a'].command).toBe('ruby');
|
||||||
|
expect(config.mcpServers['server-b'].command).toBe('node');
|
||||||
|
expect(config.mcpServers['claude-mem'].command).toBe(process.execPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves other servers when using "servers" key', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
const existingConfig = {
|
||||||
|
servers: {
|
||||||
|
'copilot-tool': { command: 'python', args: ['/tool.py'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(configPath, JSON.stringify(existingConfig));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs', 'servers');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.servers['copilot-tool']).toBeDefined();
|
||||||
|
expect(config.servers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles config with mcpServers as empty object', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles config without the servers key at all', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, JSON.stringify({ version: 1 }));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.version).toBe(1);
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('output format', () => {
|
||||||
|
it('writes pretty-printed JSON with 2-space indent', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
expect(content).toContain('\n');
|
||||||
|
expect(content).toContain(' "mcpServers"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends file with trailing newline', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
expect(content.endsWith('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { buildObservationPrompt } from '../../src/sdk/prompts.js';
|
||||||
|
|
||||||
|
describe('buildObservationPrompt', () => {
|
||||||
|
it('instructs the observer to avoid prose skip responses', () => {
|
||||||
|
const prompt = buildObservationPrompt({
|
||||||
|
id: 1,
|
||||||
|
tool_name: 'exec_command',
|
||||||
|
tool_input: JSON.stringify({ cmd: 'pwd' }),
|
||||||
|
tool_output: JSON.stringify({ output: '/repo' }),
|
||||||
|
created_at_epoch: Date.now(),
|
||||||
|
cwd: '/repo',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain('Return either one or more <observation>...</observation> blocks, or an empty response');
|
||||||
|
expect(prompt).toContain('Concrete debugging findings from logs, queue state, database rows, session routing, or code-path inspection');
|
||||||
|
expect(prompt).toContain('Never reply with prose such as "Skipping", "No substantive tool executions"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -212,6 +212,36 @@ describe('ResponseProcessor', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('non-XML observer responses', () => {
|
||||||
|
it('warns when the observer returns prose that will be discarded', async () => {
|
||||||
|
const session = createMockSession();
|
||||||
|
const responseText = 'Skipping — repeated log scan with no new findings.';
|
||||||
|
|
||||||
|
await processAgentResponse(
|
||||||
|
responseText,
|
||||||
|
session,
|
||||||
|
mockDbManager,
|
||||||
|
mockSessionManager,
|
||||||
|
mockWorker,
|
||||||
|
100,
|
||||||
|
null,
|
||||||
|
'TestAgent'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'PARSER',
|
||||||
|
'TestAgent returned non-XML response; observation content was discarded',
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: 1,
|
||||||
|
preview: responseText
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const [, , observations, summary] = mockStoreObservations.mock.calls[0];
|
||||||
|
expect(observations).toHaveLength(0);
|
||||||
|
expect(summary).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parsing summary from XML response', () => {
|
describe('parsing summary from XML response', () => {
|
||||||
it('should parse summary from response', async () => {
|
it('should parse summary from response', async () => {
|
||||||
const session = createMockSession();
|
const session = createMockSession();
|
||||||
|
|||||||
Reference in New Issue
Block a user