diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0f2add2..5f28d54 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -12,6 +12,13 @@ before: - bash -c "cd ui && npm ci --silent && npm run build" - bash -c "rm -rf internal/worker/static && mkdir -p internal/worker/static && cp -r ui/dist/* internal/worker/static/" +# Generate versioned plugin configuration files (GoReleaser Pro feature) +template_files: + - src: plugin/.claude-plugin/plugin.json.tpl + dst: .claude-plugin/plugin.json + - src: plugin/.claude-plugin/marketplace.json.tpl + dst: .claude-plugin/marketplace.json + builds: # Worker service - id: worker @@ -211,7 +218,7 @@ archives: {{- .Os }}_ {{- .Arch }} files: - - src: plugin/.claude-plugin/* + - src: dist/.claude-plugin/* dst: .claude-plugin strip_parent: true - src: plugin/hooks/hooks.json diff --git a/Makefile b/Makefile index 9f33b5f..f8a7cb0 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,7 @@ hooks: go build $(BUILD_TAGS) $(LDFLAGS) -o $(BUILD_DIR)/hooks/post-tool-use ./cmd/hooks/post-tool-use go build $(BUILD_TAGS) $(LDFLAGS) -o $(BUILD_DIR)/hooks/subagent-stop ./cmd/hooks/subagent-stop go build $(BUILD_TAGS) $(LDFLAGS) -o $(BUILD_DIR)/hooks/stop ./cmd/hooks/stop + go build $(BUILD_TAGS) $(LDFLAGS) -o $(BUILD_DIR)/hooks/statusline ./cmd/hooks/statusline # Build MCP server mcp: @@ -65,6 +66,7 @@ build-linux: GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/hooks/post-tool-use ./cmd/hooks/post-tool-use GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/hooks/subagent-stop ./cmd/hooks/subagent-stop GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/hooks/stop ./cmd/hooks/stop + GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/hooks/statusline ./cmd/hooks/statusline build-darwin: @echo "Building for macOS..." @@ -83,6 +85,8 @@ build-darwin: GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-arm64/hooks/subagent-stop ./cmd/hooks/subagent-stop GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-amd64/hooks/stop ./cmd/hooks/stop GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-arm64/hooks/stop ./cmd/hooks/stop + GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-amd64/hooks/statusline ./cmd/hooks/statusline + GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-arm64/hooks/statusline ./cmd/hooks/statusline build-windows: @echo "Building for Windows..." @@ -94,6 +98,7 @@ build-windows: GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/hooks/post-tool-use.exe ./cmd/hooks/post-tool-use GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/hooks/subagent-stop.exe ./cmd/hooks/subagent-stop GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/hooks/stop.exe ./cmd/hooks/stop + GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/hooks/statusline.exe ./cmd/hooks/statusline # Stop any running worker stop-worker: @@ -107,8 +112,8 @@ stop-worker: start-worker: @echo "Starting worker..." @# Prefer cache directory (where Claude Code looks), fall back to marketplaces - @if [ -f "$(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0/worker" ]; then \ - nohup $(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \ + @if [ -f "$(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker" ]; then \ + nohup $(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \ else \ nohup $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \ fi @@ -132,22 +137,11 @@ install: build stop-worker cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/ cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/ cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/ - cp $(PLUGIN_DIR)/.claude-plugin/plugin.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/ - cp $(PLUGIN_DIR)/.claude-plugin/marketplace.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/ - @# Also install to cache directory (where Claude Code looks for plugins) - @if [ -d "$(HOME)/.claude/plugins/cache/claude-mnemonic" ]; then \ - echo "Updating plugin in cache directory..."; \ - CACHE_DIR=$$(find $(HOME)/.claude/plugins/cache/claude-mnemonic -type d -name "hooks" -exec dirname {} \; 2>/dev/null | head -1); \ - if [ -n "$$CACHE_DIR" ]; then \ - cp $(BUILD_DIR)/worker "$$CACHE_DIR/"; \ - cp $(BUILD_DIR)/mcp-server "$$CACHE_DIR/"; \ - cp $(BUILD_DIR)/hooks/* "$$CACHE_DIR/hooks/"; \ - cp $(PLUGIN_DIR)/hooks/hooks.json "$$CACHE_DIR/hooks/"; \ - echo "Cache directory updated: $$CACHE_DIR"; \ - fi; \ - fi + @# Update plugin.json and marketplace.json with current version to prevent stale version directories + @sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json + @sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json @echo "Registering plugin with Claude Code..." - @./scripts/register-plugin.sh + @./scripts/register-plugin.sh "$(VERSION)" @$(MAKE) start-worker @echo "Installation complete!" diff --git a/README.md b/README.md index cd90498..38af3e9 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ cosign verify-blob \ | **Project Isolation** | Each project has its own knowledge base | | **Global Patterns** | Best practices are shared across all projects | | **Semantic Search** | Find relevant context with natural language | +| **Live Statusline** | Real-time metrics in Claude Code: `[mnemonic] ● served:42 | project:28 memories` | | **Web Dashboard** | Browse and manage memories at `localhost:37777` | ### How knowledge flows diff --git a/cmd/hooks/statusline/main.go b/cmd/hooks/statusline/main.go new file mode 100644 index 0000000..6876d84 --- /dev/null +++ b/cmd/hooks/statusline/main.go @@ -0,0 +1,307 @@ +// Package main provides the statusline hook for Claude Code. +// This binary outputs a status line showing claude-mnemonic metrics. +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "time" + + "github.com/lukaszraczylo/claude-mnemonic/pkg/hooks" +) + +// StatusInput is the JSON input from Claude Code's statusline feature. +type StatusInput struct { + HookEventName string `json:"hook_event_name"` + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + Model struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + } `json:"model"` + Workspace struct { + CurrentDir string `json:"current_dir"` + ProjectDir string `json:"project_dir"` + } `json:"workspace"` + Version string `json:"version"` + Cost struct { + TotalCostUSD float64 `json:"total_cost_usd"` + TotalDurationMS int64 `json:"total_duration_ms"` + TotalAPIDurationMS int64 `json:"total_api_duration_ms"` + TotalLinesAdded int `json:"total_lines_added"` + TotalLinesRemoved int `json:"total_lines_removed"` + } `json:"cost"` + ContextWindow struct { + TotalInputTokens int `json:"total_input_tokens"` + TotalOutputTokens int `json:"total_output_tokens"` + ContextWindowSize int `json:"context_window_size"` + } `json:"context_window"` +} + +// WorkerStats is the response from the worker's /api/stats endpoint. +type WorkerStats struct { + Uptime string `json:"uptime"` + ActiveSessions int `json:"activeSessions"` + QueueDepth int `json:"queueDepth"` + IsProcessing bool `json:"isProcessing"` + ConnectedClients int `json:"connectedClients"` + SessionsToday int `json:"sessionsToday"` + Ready bool `json:"ready"` + Project string `json:"project,omitempty"` + ProjectObservations int `json:"projectObservations,omitempty"` + Retrieval struct { + TotalRequests int64 `json:"TotalRequests"` + ObservationsServed int64 `json:"ObservationsServed"` + SearchRequests int64 `json:"SearchRequests"` + ContextInjections int64 `json:"ContextInjections"` + } `json:"retrieval"` +} + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorCyan = "\033[36m" + colorGray = "\033[90m" + colorRed = "\033[31m" +) + +func main() { + // Read input from stdin + inputData, err := io.ReadAll(os.Stdin) + if err != nil { + // On error, output minimal status + fmt.Println(formatOffline()) + return + } + + var input StatusInput + if err := json.Unmarshal(inputData, &input); err != nil { + fmt.Println(formatOffline()) + return + } + + // Determine project directory + projectDir := input.Workspace.ProjectDir + if projectDir == "" { + projectDir = input.Workspace.CurrentDir + } + if projectDir == "" { + projectDir = input.CWD + } + + // Generate project ID + project := "" + if projectDir != "" { + project = hooks.ProjectIDWithName(projectDir) + } + + // Get worker stats + stats := getWorkerStats(project) + + // Format and output statusline + fmt.Println(formatStatusLine(stats, input)) +} + +// getWorkerStats fetches stats from the worker service. +func getWorkerStats(project string) *WorkerStats { + port := hooks.GetWorkerPort() + + // Build URL with optional project parameter + endpoint := fmt.Sprintf("http://127.0.0.1:%d/api/stats", port) + if project != "" { + endpoint += "?project=" + url.QueryEscape(project) + } + + // Create HTTP client with short timeout (statusline must be fast) + client := &http.Client{Timeout: 100 * time.Millisecond} + + resp, err := client.Get(endpoint) + if err != nil { + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + var stats WorkerStats + if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { + return nil + } + + return &stats +} + +// formatStatusLine formats the status line output. +func formatStatusLine(stats *WorkerStats, input StatusInput) string { + // Check if colors are enabled (default: yes, unless TERM is dumb or NO_COLOR is set) + useColors := os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb" + if os.Getenv("CLAUDE_MNEMONIC_STATUSLINE_COLORS") == "false" { + useColors = false + } else if os.Getenv("CLAUDE_MNEMONIC_STATUSLINE_COLORS") == "true" { + useColors = true + } + + // Check format preference + format := os.Getenv("CLAUDE_MNEMONIC_STATUSLINE_FORMAT") + if format == "" { + format = "default" + } + + if stats == nil { + return formatOfflineColored(useColors) + } + + if !stats.Ready { + return formatStartingColored(useColors) + } + + switch format { + case "compact": + return formatCompact(stats, useColors) + case "minimal": + return formatMinimal(stats, useColors) + default: + return formatDefault(stats, useColors) + } +} + +// formatDefault returns the default status line format. +func formatDefault(stats *WorkerStats, useColors bool) string { + // [mnemonic] ● served:42 | injected:5 | searches:3 | project:28 memories + var prefix, indicator, reset string + if useColors { + prefix = colorCyan + "[mnemonic]" + colorReset + indicator = colorGreen + "●" + colorReset + reset = colorReset + } else { + prefix = "[mnemonic]" + indicator = "●" + } + + // Build status parts with clear labels + parts := []string{} + + // Total memories served to Claude this session + parts = append(parts, fmt.Sprintf("served:%d", stats.Retrieval.ObservationsServed)) + + // Context injections (memories auto-loaded at session start) + if stats.Retrieval.ContextInjections > 0 { + parts = append(parts, fmt.Sprintf("injected:%d", stats.Retrieval.ContextInjections)) + } + + // Semantic searches performed + if stats.Retrieval.SearchRequests > 0 { + parts = append(parts, fmt.Sprintf("searches:%d", stats.Retrieval.SearchRequests)) + } + + // Project-specific memory count + if stats.ProjectObservations > 0 { + if useColors { + parts = append(parts, fmt.Sprintf("%sproject:%d memories%s", colorYellow, stats.ProjectObservations, reset)) + } else { + parts = append(parts, fmt.Sprintf("project:%d memories", stats.ProjectObservations)) + } + } + + // Processing indicator + if stats.IsProcessing || stats.QueueDepth > 0 { + if useColors { + parts = append(parts, colorYellow+"processing..."+colorReset) + } else { + parts = append(parts, "processing...") + } + } + + result := prefix + " " + indicator + for i, part := range parts { + if i == 0 { + result += " " + part + } else { + result += " | " + part + } + } + + return result +} + +// formatCompact returns a compact status line. +func formatCompact(stats *WorkerStats, useColors bool) string { + // [m] ● 42/5/3 (28) + var prefix, indicator string + if useColors { + prefix = colorCyan + "[m]" + colorReset + indicator = colorGreen + "●" + colorReset + } else { + prefix = "[m]" + indicator = "●" + } + + result := fmt.Sprintf("%s %s %d/%d/%d", + prefix, indicator, + stats.Retrieval.ObservationsServed, + stats.Retrieval.ContextInjections, + stats.Retrieval.SearchRequests, + ) + + if stats.ProjectObservations > 0 { + result += fmt.Sprintf(" (%d)", stats.ProjectObservations) + } + + if stats.IsProcessing || stats.QueueDepth > 0 { + if useColors { + result += " " + colorYellow + "⚙" + colorReset + } else { + result += " ⚙" + } + } + + return result +} + +// formatMinimal returns a minimal status line. +func formatMinimal(stats *WorkerStats, useColors bool) string { + // ● 42 obs + var indicator string + if useColors { + indicator = colorGreen + "●" + colorReset + } else { + indicator = "●" + } + + result := fmt.Sprintf("%s %d", indicator, stats.Retrieval.ObservationsServed) + + if stats.ProjectObservations > 0 { + result += fmt.Sprintf("/%d", stats.ProjectObservations) + } + + return result +} + +// formatOffline returns the offline status. +func formatOffline() string { + return formatOfflineColored(true) +} + +// formatOfflineColored returns the offline status with optional colors. +func formatOfflineColored(useColors bool) string { + if useColors { + return colorCyan + "[mnemonic]" + colorReset + " " + colorGray + "○" + colorReset + } + return "[mnemonic] ○" +} + +// formatStartingColored returns the starting status with optional colors. +func formatStartingColored(useColors bool) string { + if useColors { + return colorCyan + "[mnemonic]" + colorReset + " " + colorYellow + "◐" + colorReset + " starting" + } + return "[mnemonic] ◐ starting" +} diff --git a/docs/src/App.vue b/docs/src/App.vue index 1b27cc0..aa6122f 100644 --- a/docs/src/App.vue +++ b/docs/src/App.vue @@ -431,7 +431,7 @@ const features = [ { icon: 'fas fa-folder-tree', title: 'Project-aware context', description: 'Your React knowledge stays in React projects. Your Go patterns stay in Go projects. No context pollution.' }, { icon: 'fas fa-globe', title: 'Shared best practices', description: 'Security patterns, performance tips, and universal learnings automatically available across all your projects.' }, { icon: 'fas fa-search', title: 'Finds what matters', description: 'Semantic search finds relevant memories even when you don\'t remember the exact words. "That auth thing" just works.' }, - { icon: 'fas fa-credit-card', title: 'No API keys needed', description: 'Uses Claude Code CLI - works with your existing Claude Pro/Max subscription. No separate API costs.' }, + { icon: 'fas fa-chart-line', title: 'Live statusline', description: 'Real-time metrics right in Claude Code: memories served, searches performed, and project memory count at a glance.' }, { icon: 'fas fa-lock', title: '100% private', description: 'Your code context never leaves your machine. No telemetry. No cloud sync. Your memories are yours.' }, ] diff --git a/internal/db/sqlite/observation.go b/internal/db/sqlite/observation.go index 46a0321..3b4eb32 100644 --- a/internal/db/sqlite/observation.go +++ b/internal/db/sqlite/observation.go @@ -74,12 +74,16 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p id, _ := result.LastInsertId() - // Cleanup old observations beyond the limit for this project + // Cleanup old observations beyond the limit for this project (async to not block handler) if project != "" { - deletedIDs, _ := s.CleanupOldObservations(ctx, project) - if len(deletedIDs) > 0 && s.cleanupFunc != nil { - s.cleanupFunc(ctx, deletedIDs) - } + go func(proj string) { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + deletedIDs, _ := s.CleanupOldObservations(cleanupCtx, proj) + if len(deletedIDs) > 0 && s.cleanupFunc != nil { + s.cleanupFunc(cleanupCtx, deletedIDs) + } + }(project) } return id, nowEpoch, nil diff --git a/internal/db/sqlite/prompt.go b/internal/db/sqlite/prompt.go index e4a9b32..4bde673 100644 --- a/internal/db/sqlite/prompt.go +++ b/internal/db/sqlite/prompt.go @@ -51,11 +51,15 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi id, _ := result.LastInsertId() - // Cleanup old prompts beyond the global limit - deletedIDs, _ := s.CleanupOldPrompts(ctx) - if len(deletedIDs) > 0 && s.cleanupFunc != nil { - s.cleanupFunc(ctx, deletedIDs) - } + // Cleanup old prompts beyond the global limit (async to not block handler) + go func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + deletedIDs, _ := s.CleanupOldPrompts(cleanupCtx) + if len(deletedIDs) > 0 && s.cleanupFunc != nil { + s.cleanupFunc(cleanupCtx, deletedIDs) + } + }() return id, nil } diff --git a/internal/worker/handlers.go b/internal/worker/handlers.go index 95f55a2..9d195d2 100644 --- a/internal/worker/handlers.go +++ b/internal/worker/handlers.go @@ -514,7 +514,7 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) { retrievalStats := s.GetRetrievalStats() sessionsToday, _ := s.sessionStore.GetSessionsToday(r.Context()) - writeJSON(w, map[string]interface{}{ + response := map[string]interface{}{ "uptime": time.Since(s.startTime).String(), "activeSessions": s.sessionManager.GetActiveSessionCount(), "queueDepth": s.sessionManager.GetTotalQueueDepth(), @@ -522,7 +522,19 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) { "connectedClients": s.sseBroadcaster.ClientCount(), "sessionsToday": sessionsToday, "retrieval": retrievalStats, - }) + "ready": s.ready.Load(), + } + + // Include project-specific observation count if project is specified + if project := r.URL.Query().Get("project"); project != "" { + count, err := s.observationStore.GetObservationCount(r.Context(), project) + if err == nil { + response["projectObservations"] = count + response["project"] = project + } + } + + writeJSON(w, response) } // handleGetRetrievalStats returns detailed retrieval statistics. diff --git a/internal/worker/sse/broadcaster.go b/internal/worker/sse/broadcaster.go index 1b2934e..b6e8d96 100644 --- a/internal/worker/sse/broadcaster.go +++ b/internal/worker/sse/broadcaster.go @@ -6,10 +6,17 @@ import ( "fmt" "net/http" "sync" + "time" "github.com/rs/zerolog/log" ) +const ( + // WriteTimeout is the timeout for writing to SSE clients. + // Prevents blocking on stale connections. + WriteTimeout = 2 * time.Second +) + // Client represents a connected SSE client. type Client struct { ID string @@ -101,6 +108,7 @@ func (b *Broadcaster) removeClientByID(id string) { } // Broadcast sends a message to all connected clients. +// Uses non-blocking writes with timeout to prevent stale connections from blocking. func (b *Broadcaster) Broadcast(data interface{}) { jsonData, err := json.Marshal(data) if err != nil { @@ -117,30 +125,67 @@ func (b *Broadcaster) Broadcast(data interface{}) { } b.mu.RUnlock() - // Track dead clients for removal - var deadClients []*Client + if len(clients) == 0 { + return + } + + // Use a channel to collect dead clients from concurrent writes + deadClientsCh := make(chan string, len(clients)) + var wg sync.WaitGroup for _, client := range clients { select { case <-client.Done: continue default: - _, err := client.Writer.Write([]byte(message)) - if err != nil { - log.Debug(). - Str("clientId", client.ID). - Err(err). - Msg("Failed to write to SSE client, marking for removal") - deadClients = append(deadClients, client) - continue - } - client.Flusher.Flush() + wg.Add(1) + go func(c *Client) { + defer wg.Done() + b.writeToClient(c, message, deadClientsCh) + }(client) } } - // Remove dead clients outside the iteration - for _, client := range deadClients { - b.removeClientByID(client.ID) + // Wait for all writes to complete (with their individual timeouts) + wg.Wait() + close(deadClientsCh) + + // Remove dead clients + for clientID := range deadClientsCh { + b.removeClientByID(clientID) + } +} + +// writeToClient writes a message to a single client with timeout. +func (b *Broadcaster) writeToClient(client *Client, message string, deadCh chan<- string) { + // Use a timeout channel to prevent blocking on stale connections + done := make(chan struct{}) + + go func() { + defer close(done) + _, err := client.Writer.Write([]byte(message)) + if err != nil { + log.Debug(). + Str("clientId", client.ID). + Err(err). + Msg("Failed to write to SSE client, marking for removal") + deadCh <- client.ID + return + } + client.Flusher.Flush() + }() + + select { + case <-done: + // Write completed successfully + case <-time.After(WriteTimeout): + log.Warn(). + Str("clientId", client.ID). + Dur("timeout", WriteTimeout). + Msg("SSE write timed out, marking client for removal") + deadCh <- client.ID + case <-client.Done: + // Client disconnected during write } } diff --git a/plugin/.claude-plugin/marketplace.json.tpl b/plugin/.claude-plugin/marketplace.json.tpl new file mode 100644 index 0000000..343eca7 --- /dev/null +++ b/plugin/.claude-plugin/marketplace.json.tpl @@ -0,0 +1,22 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "claude-mnemonic", + "version": "{{ .Version }}", + "description": "Persistent memory system for Claude Code - stores observations, session summaries, and user prompts with semantic search", + "owner": { + "name": "lukaszraczylo", + "email": "lukaszraczylo@users.noreply.github.com" + }, + "plugins": [ + { + "name": "claude-mnemonic", + "description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB", + "version": "{{ .Version }}", + "author": { + "name": "lukaszraczylo" + }, + "source": "./", + "category": "productivity" + } + ] +} diff --git a/plugin/.claude-plugin/plugin.json.tpl b/plugin/.claude-plugin/plugin.json.tpl new file mode 100644 index 0000000..b6124f9 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json.tpl @@ -0,0 +1,9 @@ +{ + "name": "claude-mnemonic", + "version": "{{ .Version }}", + "description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB", + "author": { + "name": "lukaszraczylo", + "email": "lukaszraczylo@users.noreply.github.com" + } +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fbd0eb7..73181ed 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -27,9 +27,28 @@ function Write-Error { param($Message) Write-Host "[ERROR] $Message" -Foreground function Get-LatestVersion { try { - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$GitHubRepo/releases/latest" + $headers = @{} + if ($env:GITHUB_TOKEN) { + $headers["Authorization"] = "token $env:GITHUB_TOKEN" + } + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$GitHubRepo/releases/latest" -Headers $headers return $release.tag_name } catch { + $errorMsg = $_.Exception.Message + if ($errorMsg -match "rate limit" -or $_.Exception.Response.StatusCode -eq 403) { + Write-Host "" + Write-Host "[ERROR] GitHub API rate limit exceeded." -ForegroundColor Red + Write-Host "" + Write-Host "You have a few options:" -ForegroundColor Yellow + Write-Host " 1. Wait ~1 hour for the rate limit to reset" + Write-Host " 2. Specify a version manually:" + Write-Host " `$env:MNEMONIC_VERSION = 'v0.6.1'; irm https://raw.githubusercontent.com/$GitHubRepo/main/scripts/install.ps1 | iex" -ForegroundColor Cyan + Write-Host " 3. Use a GitHub token (set `$env:GITHUB_TOKEN)" + Write-Host " 4. Clone and build from source:" + Write-Host " git clone https://github.com/$GitHubRepo.git" -ForegroundColor Cyan + Write-Host " cd claude-mnemonic; make build; make install" -ForegroundColor Cyan + exit 1 + } Write-Error "Failed to fetch latest version from GitHub: $_" } } @@ -88,6 +107,14 @@ function Register-Plugin { # Ensure directories exist New-Item -ItemType Directory -Path "$env:USERPROFILE\.claude\plugins" -Force | Out-Null + + # Clean up old cache versions to prevent stale binaries + $CacheBase = Split-Path $CachePath -Parent + if (Test-Path $CacheBase) { + Write-Info "Cleaning up old cache versions..." + Get-ChildItem -Path $CacheBase -Directory | Where-Object { $_.Name -ne $VersionClean } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + } + New-Item -ItemType Directory -Path $CachePath -Force | Out-Null # Create JSON files if they don't exist @@ -132,8 +159,19 @@ function Register-Plugin { $Settings | Add-Member -NotePropertyName "enabledPlugins" -NotePropertyValue @{} -Force } $Settings.enabledPlugins | Add-Member -NotePropertyName $PluginKey -NotePropertyValue $true -Force + + # Configure statusline + $StatuslineCmd = "$InstallDir\hooks\statusline.exe" + $StatuslineEntry = @{ + type = "command" + command = $StatuslineCmd + padding = 0 + } + $Settings | Add-Member -NotePropertyName "statusLine" -NotePropertyValue $StatuslineEntry -Force + $Settings | ConvertTo-Json -Depth 10 | Out-File -Encoding UTF8 $SettingsFile Write-Success "Plugin enabled in settings.json" + Write-Success "Statusline configured in settings.json" # Update known_marketplaces.json $Marketplaces = Get-Content $MarketplacesFile -Raw | ConvertFrom-Json @@ -194,8 +232,12 @@ function Uninstall-ClaudeMnemonic { $Settings = Get-Content $SettingsFile -Raw | ConvertFrom-Json if ($Settings.enabledPlugins) { $Settings.enabledPlugins.PSObject.Properties.Remove($PluginKey) - $Settings | ConvertTo-Json -Depth 10 | Out-File -Encoding UTF8 $SettingsFile } + # Remove statusline if it's ours + if ($Settings.statusLine -and $Settings.statusLine.command -match "claude-mnemonic") { + $Settings.PSObject.Properties.Remove("statusLine") + } + $Settings | ConvertTo-Json -Depth 10 | Out-File -Encoding UTF8 $SettingsFile } if (Test-Path $MarketplacesFile) { $Marketplaces = Get-Content $MarketplacesFile -Raw | ConvertFrom-Json diff --git a/scripts/install.sh b/scripts/install.sh index 58010fc..95334ed 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -81,11 +81,44 @@ detect_platform() { # Get the latest release version from GitHub get_latest_version() { - local version - version=$(curl -sS "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + local response version curl_opts + + # Use GitHub token if available (higher rate limit) + curl_opts=(-sS) + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + curl_opts+=(-H "Authorization: token ${GITHUB_TOKEN}") + fi + + # Fetch with error handling + response=$(curl "${curl_opts[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>&1) + + # Check for rate limiting + if echo "$response" | grep -q "API rate limit exceeded"; then + echo "" + error "GitHub API rate limit exceeded. + +You have a few options: + 1. Wait ~1 hour for the rate limit to reset + 2. Specify a version manually: + curl -sSL https://raw.githubusercontent.com/${GITHUB_REPO}/main/scripts/install.sh | bash -s -- v0.6.1 + 3. Use a GitHub token (set GITHUB_TOKEN environment variable) + 4. Clone and build from source: + git clone https://github.com/${GITHUB_REPO}.git + cd claude-mnemonic && make build && make install" + fi + + # Check for other API errors + if echo "$response" | grep -q '"message":'; then + local msg + msg=$(echo "$response" | grep '"message":' | sed -E 's/.*"message": *"([^"]+)".*/\1/') + error "GitHub API error: $msg" + fi + + # Extract version + version=$(echo "$response" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') if [[ -z "$version" ]]; then - error "Failed to fetch latest version from GitHub" + error "Failed to fetch latest version from GitHub. Response: $response" fi echo "$version" @@ -151,6 +184,15 @@ register_plugin() { # Ensure directories exist mkdir -p "$HOME/.claude/plugins" + + # Clean up old cache versions to prevent stale binaries + local cache_base + cache_base=$(dirname "$CACHE_DIR") + if [[ -d "$cache_base" ]]; then + info "Cleaning up old cache versions..." + find "$cache_base" -mindepth 1 -maxdepth 1 -type d ! -name "${version#v}" -exec rm -rf {} \; 2>/dev/null || true + fi + mkdir -p "${CACHE_DIR}/${version}" # Create JSON files if they don't exist @@ -193,12 +235,24 @@ EOF success "Plugin registered in installed_plugins.json" - # Enable in settings.json - jq --arg key "$PLUGIN_KEY" \ - '.enabledPlugins //= {} | .enabledPlugins[$key] = true' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \ + # Enable in settings.json and configure statusline + local statusline_cmd="$INSTALL_DIR/hooks/statusline" + local statusline_entry + statusline_entry=$(cat < "${SETTINGS_FILE}.tmp" \ && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" success "Plugin enabled in settings.json" + success "Statusline configured in settings.json" # Register marketplace local marketplace_entry @@ -394,7 +448,8 @@ if [[ "${1:-}" == "--uninstall" ]]; then jq 'del(.plugins["'"$PLUGIN_KEY"'"])' "$PLUGINS_FILE" > "${PLUGINS_FILE}.tmp" && mv "${PLUGINS_FILE}.tmp" "$PLUGINS_FILE" fi if [[ -f "$SETTINGS_FILE" ]]; then - jq 'del(.enabledPlugins["'"$PLUGIN_KEY"'"])' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" + # Remove plugin from enabled plugins and remove statusline if it's ours + jq 'del(.enabledPlugins["'"$PLUGIN_KEY"'"]) | if .statusLine.command | test("claude-mnemonic") then del(.statusLine) else . end' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" fi if [[ -f "$MARKETPLACES_FILE" ]]; then jq 'del(.["claude-mnemonic"])' "$MARKETPLACES_FILE" > "${MARKETPLACES_FILE}.tmp" && mv "${MARKETPLACES_FILE}.tmp" "$MARKETPLACES_FILE" diff --git a/scripts/register-plugin.sh b/scripts/register-plugin.sh index bee859e..c0961da 100755 --- a/scripts/register-plugin.sh +++ b/scripts/register-plugin.sh @@ -9,13 +9,22 @@ MARKETPLACES_FILE="$HOME/.claude/plugins/known_marketplaces.json" PLUGIN_KEY="claude-mnemonic@claude-mnemonic" MARKETPLACE_NAME="claude-mnemonic" MARKETPLACE_PATH="$HOME/.claude/plugins/marketplaces/claude-mnemonic" -CACHE_PATH="$HOME/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0" -VERSION="1.0.0" + +# Get version from git tags (same as Makefile), or use argument if provided +VERSION="${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}" +CACHE_BASE="$HOME/.claude/plugins/cache/claude-mnemonic/claude-mnemonic" +CACHE_PATH="$CACHE_BASE/$VERSION" TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") # Ensure plugins directory exists mkdir -p "$HOME/.claude/plugins" +# Clean up old cache versions to prevent stale binaries +if [ -d "$CACHE_BASE" ]; then + echo "Cleaning up old cache versions..." + find "$CACHE_BASE" -mindepth 1 -maxdepth 1 -type d ! -name "$VERSION" -exec rm -rf {} \; 2>/dev/null || true +fi + # Create installed_plugins.json if it doesn't exist if [ ! -f "$PLUGINS_FILE" ]; then echo '{"version": 2, "plugins": {}}' > "$PLUGINS_FILE" @@ -60,13 +69,24 @@ EOF echo "Plugin registered in installed_plugins.json" - # Enable the plugin in settings.json + # Enable the plugin in settings.json and configure statusline # First ensure enabledPlugins object exists, then add our plugin - jq --arg key "$PLUGIN_KEY" \ - '.enabledPlugins //= {} | .enabledPlugins[$key] = true' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \ + STATUSLINE_CMD="$MARKETPLACE_PATH/hooks/statusline" + STATUSLINE_ENTRY=$(cat < "${SETTINGS_FILE}.tmp" \ && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" echo "Plugin enabled in settings.json" + echo "Statusline configured in settings.json" # Register the marketplace in known_marketplaces.json MARKETPLACE_ENTRY=$(cat <