mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
Add the statusline. Fix the installation.
This commit is contained in:
+8
-1
@@ -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
|
||||
|
||||
@@ -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!"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+1
-1
@@ -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.' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+44
-2
@@ -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
|
||||
|
||||
+62
-7
@@ -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 <<EOF
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$statusline_cmd",
|
||||
"padding": 0
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
jq --arg key "$PLUGIN_KEY" --argjson statusline "$statusline_entry" \
|
||||
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline' "$SETTINGS_FILE" > "${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"
|
||||
|
||||
@@ -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 <<EOF
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$STATUSLINE_CMD",
|
||||
"padding": 0
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
jq --arg key "$PLUGIN_KEY" --argjson statusline "$STATUSLINE_ENTRY" \
|
||||
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline' "$SETTINGS_FILE" > "${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 <<EOF
|
||||
|
||||
Reference in New Issue
Block a user