Compare commits

...
34 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub 96e64a5796 Update go.mod and go.sum (#36) 2026-03-28 03:50:55 +00:00
github-actions[bot] ef92d710b8 chore: update marketplace for v0.11.73 2026-03-17 03:59:52 +00:00
lukaszraczyloandGitHub f8702cb0c2 Update go.mod and go.sum (#35) 2026-03-17 03:51:16 +00:00
github-actions[bot] c52bab1e95 chore: update marketplace for v0.11.69 2026-03-13 03:59:45 +00:00
lukaszraczyloandGitHub 1eec436746 Update go.mod and go.sum (#34) 2026-03-13 03:49:54 +00:00
github-actions[bot] 3ccb20df63 chore: update marketplace for v0.11.65 2026-03-09 03:58:43 +00:00
lukaszraczyloandGitHub 5fbba6bace Update go.mod and go.sum (#33) 2026-03-09 03:50:24 +00:00
github-actions[bot] de422c0de0 chore: update marketplace for v0.11.61 2026-03-07 03:51:29 +00:00
lukaszraczyloandGitHub db2a552eb8 Update go.mod and go.sum (#32) 2026-03-07 03:42:12 +00:00
github-actions[bot] 195caffc59 chore: update marketplace for v0.11.57 2026-03-07 01:34:33 +00:00
lukaszraczylo 7b979a3f95 fix: prevent internal prompts and duplicates in memory database
- Add server-side detection of SDK processor's internal system prompt
  in handleSessionInit, since CLAUDE_MNEMONIC_INTERNAL env var is not
  propagated by Claude Code to hook subprocesses
- Add cross-session duplicate detection (FindRecentPromptByTextGlobal)
  to catch same prompt text arriving from different session IDs
- Add hooks, mcpServers, and commands references to plugin.json per
  Claude Code plugin spec
- Remove MCP server injection from register-plugin.sh (now in plugin.json)
- Use ${CLAUDE_PLUGIN_ROOT} for statusline path instead of hardcoded path
- Add python3 fallback for plugin registration when jq is unavailable
- Replace hardcoded 1.0.0 version in findWorkerBinary with glob lookup
- Add cache copy verification in register-plugin.sh
- Add update-version Makefile target to keep metadata in sync
2026-03-07 01:28:04 +00:00
github-actions[bot] 49e7efd27d chore: update marketplace for v0.11.53 2026-03-07 00:36:01 +00:00
lukaszraczylo fcab3ebef4 fixup! fixup! fixup! fixup! fixup! chore: update marketplace for v0.11.37 2026-03-07 00:25:52 +00:00
lukaszraczylo a1d4459329 fixup! fixup! fixup! fixup! chore: update marketplace for v0.11.37
march-improvements
2026-03-07 00:12:13 +00:00
github-actions[bot] 5a81f37779 chore: update marketplace for v0.11.47 2026-03-06 23:53:50 +00:00
lukaszraczylo 11fd1969de fixup! fixup! fixup! chore: update marketplace for v0.11.37 2026-03-06 23:45:38 +00:00
github-actions[bot] cd00f74e2c chore: update marketplace for v0.11.43 2026-03-06 15:51:16 +00:00
lukaszraczylo 319f2ca28b fixup! fixup! chore: update marketplace for v0.11.37 2026-03-06 15:39:58 +00:00
lukaszraczylo 77f5f02510 fixup! chore: update marketplace for v0.11.37
march-improvements
2026-03-06 15:39:52 +00:00
github-actions[bot] 1a6f6b6e5e chore: update marketplace for v0.11.37 2026-02-11 04:08:18 +00:00
lukaszraczyloandGitHub b0c0b4208e Update go.mod and go.sum (#29) 2026-02-11 03:59:17 +00:00
github-actions[bot] 84aff03d7c chore: update marketplace for v0.11.33 2026-02-10 04:09:02 +00:00
lukaszraczyloandGitHub 9432958b03 Update go.mod and go.sum (#28) 2026-02-10 04:00:07 +00:00
github-actions[bot] 1c63843b6f chore: update marketplace for v0.11.29 2026-02-09 04:05:07 +00:00
lukaszraczyloandGitHub 76b1ec3c9f Update go.mod and go.sum (#27) 2026-02-09 03:55:54 +00:00
github-actions[bot] 968fd2293e chore: update marketplace for v0.11.25 2026-02-06 03:59:35 +00:00
lukaszraczyloandGitHub c123568dfd Update go.mod and go.sum (#26) 2026-02-06 03:49:52 +00:00
github-actions[bot] d69511dd9b chore: update marketplace for v0.11.21 2026-01-15 03:45:28 +00:00
lukaszraczyloandGitHub 617df091aa Update go.mod and go.sum (#25) 2026-01-15 03:34:58 +00:00
github-actions[bot] 9e4a001e7e chore: update marketplace for v0.11.17 2026-01-11 12:47:20 +00:00
lukaszraczyloandGitHub 5335a8a7a6 mnemonic ralphised (#24)
* Make things 'betterer' across the board

* fix: reorganize struct fields and config parameters for consistency

- [x] Reorder Config struct fields alphabetically and by related functionality
- [x] Reorganize Observation model fields with archival fields grouped together
- [x] Reorder ObservationStore fields to group related members
- [x] Reorder Store struct fields with health check caching grouped
- [x] Reorganize HealthInfo and PoolMetrics struct field order
- [x] Reorder maintenance Service struct fields logically
- [x] Reorganize MCP server handler parameter structs alphabetically
- [x] Reorder pattern detector candidate tracking fields
- [x] Reorganize search Manager struct fields by functionality
- [x] Reorder vector Client struct fields with mutex protections grouped
- [x] Reorganize handler request/response struct fields
- [x] Update handlers_test.go to expect wrapped response format
- [x] Reorder middleware TokenAuth and rate limiter fields
- [x] Reorganize Service struct fields with grouped functionality
- [x] Fix RateLimiter field ordering for clarity
- [x] Reorder CircuitBreaker metrics fields

* fix(security): improve JSON output safety and path traversal protection

- [x] Replace unsafe JSON string formatting with proper json.Marshal in export handler
- [x] Remove escapeJSONString helper function in favor of standard JSON marshaling
- [x] Add safeResolvePath function to validate paths and prevent directory traversal
- [x] Apply path traversal validation in captureFileMtimes operations
- [x] Cap result slice capacity in getRecentSearchQueries to prevent DoS via excessive allocation

* fix(sdk): improve path traversal protection and allocation safety

- [x] Enhance safeResolvePath with stricter validation using filepath.Rel
- [x] Reject paths containing ".." after cleaning to prevent traversal
- [x] Validate absolute paths are within cwd when cwd is specified
- [x] Apply safeResolvePath validation to GetFileContent for consistency
- [x] Add comprehensive test coverage for path traversal protection
- [x] Fix allocation safety in getRecentSearchQueries by using constant capacity

* feat(dashboard): add graph stats and vector metrics endpoints

- [x] Add handleGraphStats endpoint for knowledge graph visualization
- [x] Add handleVectorMetrics endpoint for vector database dashboard
- [x] Improve update check error handling with JSON response
- [x] Register new API routes for graph and vector metrics
- [x] Migrate Font Awesome to npm package from CDN
- [x] Fix observations API response type handling
- [x] Update package version to v0.10.5-15-g385d05a

* fixup! feat(dashboard): add graph stats and vector metrics endpoints

* test: add comprehensive test coverage across multiple packages

- [x] Add 298 tests for Python chunker functionality
- [x] Add 213 tests for chunking types and constants
- [x] Add 398 tests for TypeScript/JavaScript chunker
- [x] Add 954 tests for MCP server handlers and validation
- [x] Add 563 tests for pattern detector and analysis
- [x] Add 1149 tests for vector client cache and operations
- [x] Add 663 tests for SDK processor, circuit breaker, and deduplication
- [x] Add 731 tests for session manager lifecycle and concurrency
- [x] Add 331 tests for similarity clustering and term extraction

* fix(pattern): add nil check and fmt import for GetPatternInsight

- [x] Add `fmt` import for error formatting
- [x] Add nil check for pattern before using it
- [x] Remove duplicate comment line
2026-01-11 12:41:28 +00:00
github-actions[bot] f94e07ff6f chore: update marketplace for v0.11.13 2026-01-11 02:01:40 +00:00
lukaszraczyloandGitHub d04b60517a Make things 'betterer' across the board (#23)
* Make things 'betterer' across the board

* fix: reorganize struct fields and config parameters for consistency

- [x] Reorder Config struct fields alphabetically and by related functionality
- [x] Reorganize Observation model fields with archival fields grouped together
- [x] Reorder ObservationStore fields to group related members
- [x] Reorder Store struct fields with health check caching grouped
- [x] Reorganize HealthInfo and PoolMetrics struct field order
- [x] Reorder maintenance Service struct fields logically
- [x] Reorganize MCP server handler parameter structs alphabetically
- [x] Reorder pattern detector candidate tracking fields
- [x] Reorganize search Manager struct fields by functionality
- [x] Reorder vector Client struct fields with mutex protections grouped
- [x] Reorganize handler request/response struct fields
- [x] Update handlers_test.go to expect wrapped response format
- [x] Reorder middleware TokenAuth and rate limiter fields
- [x] Reorganize Service struct fields with grouped functionality
- [x] Fix RateLimiter field ordering for clarity
- [x] Reorder CircuitBreaker metrics fields

* fix(security): improve JSON output safety and path traversal protection

- [x] Replace unsafe JSON string formatting with proper json.Marshal in export handler
- [x] Remove escapeJSONString helper function in favor of standard JSON marshaling
- [x] Add safeResolvePath function to validate paths and prevent directory traversal
- [x] Apply path traversal validation in captureFileMtimes operations
- [x] Cap result slice capacity in getRecentSearchQueries to prevent DoS via excessive allocation

* fix(sdk): improve path traversal protection and allocation safety

- [x] Enhance safeResolvePath with stricter validation using filepath.Rel
- [x] Reject paths containing ".." after cleaning to prevent traversal
- [x] Validate absolute paths are within cwd when cwd is specified
- [x] Apply safeResolvePath validation to GetFileContent for consistency
- [x] Add comprehensive test coverage for path traversal protection
- [x] Fix allocation safety in getRecentSearchQueries by using constant capacity
2026-01-11 01:51:20 +00:00
github-actions[bot] 3107eddeb2 chore: update marketplace for v0.11.9 2026-01-10 03:44:38 +00:00
91 changed files with 19732 additions and 2888 deletions
+22 -2
View File
@@ -14,7 +14,7 @@ GOARCH ?= $(shell go env GOARCH)
export CGO_ENABLED=1
BUILD_TAGS := -tags "fts5"
.PHONY: all build clean test install lint hooks worker mcp stop-worker start-worker restart-worker dashboard website dev-website setup-libs
.PHONY: all build clean test install lint hooks worker mcp stop-worker start-worker restart-worker dashboard website dev-website setup-libs update-version
all: build
@@ -22,8 +22,20 @@ all: build
setup-libs:
@./scripts/download-onnx-libs.sh all
# Update root plugin metadata with current version
update-version:
@mkdir -p .claude-plugin
@sed 's/{{ .Version }}/$(VERSION)/g; s/{{.Version}}/$(VERSION)/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json.tpl > .claude-plugin/plugin.json
@echo "Updated .claude-plugin/plugin.json to version $(VERSION)"
@# marketplace.json contains release-specific data (URLs, SHA256 hashes) that requires manual update per release.
@# Only the top-level version field is updated here.
@if [ -f marketplace.json ]; then \
sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' marketplace.json > marketplace.json.tmp && mv marketplace.json.tmp marketplace.json; \
echo "Updated marketplace.json version fields to $(VERSION)"; \
fi
# Build all binaries
build: setup-libs dashboard worker hooks mcp
build: setup-libs update-version dashboard worker hooks mcp
# Build Vue dashboard
dashboard:
@@ -108,6 +120,9 @@ build-windows:
# Stop any running worker
stop-worker:
@echo "Stopping worker..."
@-pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
@-pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
@sleep 1
@-pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
@-pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
@-lsof -ti :37777 | xargs kill -9 2>/dev/null || true
@@ -135,6 +150,10 @@ restart-worker: stop-worker start-worker
# Install to Claude plugins directory
install: build stop-worker
@echo "Installing to Claude plugins directory..."
@# Verify build output binaries exist
@test -f $(BUILD_DIR)/worker || { echo "ERROR: $(BUILD_DIR)/worker not found. Build may have failed."; exit 1; }
@test -f $(BUILD_DIR)/mcp-server || { echo "ERROR: $(BUILD_DIR)/mcp-server not found. Build may have failed."; exit 1; }
@test -d $(BUILD_DIR)/hooks || { echo "ERROR: $(BUILD_DIR)/hooks not found. Build may have failed."; exit 1; }
@# Install to marketplaces directory (for direct installs)
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
@@ -228,6 +247,7 @@ help:
@echo " make fmt - Format code"
@echo " make clean - Clean build artifacts"
@echo " make dev - Run worker in development mode"
@echo " make update-version - Update version in plugin metadata files"
@echo " make deps - Download dependencies"
@echo " make website - Build website for production"
@echo " make dev-website - Run website dev server"
+32 -10
View File
@@ -2,8 +2,10 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
@@ -51,6 +53,10 @@ var skipTools = map[string]bool{
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("PostToolUse", true)
return
}
hooks.RunHook("PostToolUse", handlePostToolUse)
}
@@ -63,15 +69,31 @@ func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) {
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName)
// Send observation to worker
_, err := hooks.POST(ctx.Port, "/api/sessions/observations", map[string]interface{}{
"claudeSessionId": ctx.SessionID,
"project": ctx.Project,
"tool_name": input.ToolName,
"tool_input": input.ToolInput,
"tool_response": input.ToolResponse,
"cwd": ctx.CWD,
})
// Fire-and-forget: send the observation without waiting for the response.
// The worker just queues it -- we don't need the response data.
// Use a short-lived context to ensure the request body is at least sent
// before this process exits.
done := make(chan struct{})
go func() {
defer close(done)
sendCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_ = hooks.POSTWithContext(sendCtx, ctx.Port, "/api/sessions/observations", map[string]interface{}{
"claudeSessionId": ctx.SessionID,
"project": ctx.Project,
"tool_name": input.ToolName,
"tool_input": input.ToolInput,
"tool_response": input.ToolResponse,
"cwd": ctx.CWD,
})
}()
return "", err
// Wait briefly for the TCP connection to be established and request sent,
// but don't block the hook for the full response.
select {
case <-done:
case <-time.After(100 * time.Millisecond):
}
return "", nil
}
+81 -10
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"os"
"strings"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
@@ -27,10 +28,17 @@ type Observation struct {
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("SessionStart", true)
return
}
hooks.RunHook("SessionStart", handleSessionStart)
}
func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
deadline, cancel := hooks.HookDeadline(30 * time.Second)
defer cancel()
// Fetch observations for context injection
endpoint := fmt.Sprintf("/api/context/inject?project=%s&cwd=%s",
url.QueryEscape(ctx.Project),
@@ -59,12 +67,21 @@ func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Injecting %d observations from project memory (%d detailed, %d condensed)\n",
len(obsData), min(fullCount, len(obsData)), max(0, len(obsData)-fullCount))
// Token budget for context injection
maxTokens := 16000 // default; could be made configurable via worker config endpoint
currentTokens := 0
// Build context string
contextBuilder := "<claude-mnemonic-context>\n"
contextBuilder += fmt.Sprintf("# Project Memory (%d observations)\n", len(obsData))
contextBuilder += "Use this knowledge to answer questions without re-exploring the codebase.\n\n"
header := fmt.Sprintf("<claude-mnemonic-context>\n# Project Memory (%d observations)\nUse this knowledge to answer questions without re-exploring the codebase.\n\n", len(obsData))
currentTokens += estimateTokens(header)
contextBuilder := header
for i, o := range obsData {
if deadline.Err() != nil {
contextBuilder += "\n... (returning early due to time limit)\n"
break
}
obs, ok := o.(map[string]interface{})
if !ok {
continue
@@ -73,40 +90,94 @@ func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
title := getString(obs, "title")
obsType := getString(obs, "type")
var obsText string
// First `fullCount` observations get full detail, rest are condensed
if i < fullCount {
// Full detail: include narrative and facts
narrative := getString(obs, "narrative")
contextBuilder += fmt.Sprintf("## %d. [%s] %s\n", i+1, strings.ToUpper(obsType), title)
obsText = fmt.Sprintf("## %d. [%s] %s\n", i+1, strings.ToUpper(obsType), title)
if narrative != "" {
contextBuilder += narrative + "\n"
obsText += narrative + "\n"
}
if facts, ok := obs["facts"].([]interface{}); ok && len(facts) > 0 {
contextBuilder += "Key facts:\n"
obsText += "Key facts:\n"
for _, f := range facts {
if fact, ok := f.(string); ok && fact != "" {
contextBuilder += fmt.Sprintf("- %s\n", fact)
obsText += fmt.Sprintf("- %s\n", fact)
}
}
}
contextBuilder += "\n"
obsText += "\n"
} else {
// Condensed: just title and subtitle (one line)
subtitle := getString(obs, "subtitle")
if subtitle != "" {
contextBuilder += fmt.Sprintf("- [%s] %s: %s\n", strings.ToUpper(obsType), title, subtitle)
obsText = fmt.Sprintf("- [%s] %s: %s\n", strings.ToUpper(obsType), title, subtitle)
} else {
contextBuilder += fmt.Sprintf("- [%s] %s\n", strings.ToUpper(obsType), title)
obsText = fmt.Sprintf("- [%s] %s\n", strings.ToUpper(obsType), title)
}
}
obsTokens := estimateTokens(obsText)
if currentTokens+obsTokens > maxTokens {
contextBuilder += fmt.Sprintf("\n... (%d more observations omitted due to token budget)\n", len(obsData)-i)
break
}
contextBuilder += obsText
currentTokens += obsTokens
}
contextBuilder += "</claude-mnemonic-context>\n"
return contextBuilder, nil
}
// estimateTokens provides a more accurate token count estimate.
// Uses word count * 1.3 as base, with adjustments for code and non-ASCII.
func estimateTokens(s string) int {
if len(s) == 0 {
return 0
}
// Count words (split on whitespace)
words := len(strings.Fields(s))
if words == 0 {
// No whitespace = probably a single token or code blob
return (len(s) + 3) / 4
}
// Base estimate: ~1.3 tokens per word for English text
estimate := int(float64(words) * 1.3)
// Detect code-heavy content (high non-alpha ratio)
nonAlpha := 0
nonASCII := 0
for _, r := range s {
if r > 127 {
nonASCII++
} else if !('a' <= r && r <= 'z') && !('A' <= r && r <= 'Z') && !('0' <= r && r <= '9') && r != ' ' {
nonAlpha++
}
}
totalChars := len(s)
// Code adjustment: more special chars = more tokens per word
if totalChars > 0 && float64(nonAlpha)/float64(totalChars) > 0.15 {
estimate = int(float64(estimate) * 1.3)
}
// Non-ASCII adjustment: CJK and other scripts use more tokens
if totalChars > 0 && float64(nonASCII)/float64(totalChars) > 0.1 {
estimate += nonASCII // Roughly 1 extra token per non-ASCII char
}
return estimate
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
+1 -1
View File
@@ -117,7 +117,7 @@ func getWorkerStats(port int, project string) *WorkerStats {
if err != nil {
return nil
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil
+52 -13
View File
@@ -5,12 +5,16 @@ import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
var debug = os.Getenv("CLAUDE_MNEMONIC_DEBUG") != ""
// Input is the hook input from Claude Code.
type Input struct {
hooks.BaseInput
@@ -62,7 +66,19 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
if err != nil {
return "", ""
}
defer file.Close()
defer func() { _ = file.Close() }()
// For large transcripts, seek to the last 256KB for efficiency.
// We only need the last user/assistant messages, not the entire history.
const tailSize = 256 * 1024
info, err := file.Stat()
if err == nil && info.Size() > tailSize {
if _, seekErr := file.Seek(-tailSize, io.SeekEnd); seekErr == nil {
// Discard partial first line after seek
discardScanner := bufio.NewScanner(file)
discardScanner.Scan()
}
}
scanner := bufio.NewScanner(file)
// Increase buffer size for large messages
@@ -97,12 +113,20 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("Stop", true)
return
}
hooks.RunHook("Stop", handleStop)
}
func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
// Debug: dump raw input
fmt.Fprintf(os.Stderr, "[stop] Raw input: %s\n", string(ctx.RawInput))
deadline, cancel := hooks.HookDeadline(30 * time.Second)
defer cancel()
if debug {
fmt.Fprintf(os.Stderr, "[stop] Raw input: %s\n", string(ctx.RawInput))
}
// Find session
result, err := hooks.GET(ctx.Port, fmt.Sprintf("/api/sessions?claudeSessionId=%s", ctx.SessionID))
@@ -122,18 +146,33 @@ func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
lastUser, lastAssistant = parseTranscript(input.TranscriptPath)
}
// Debug: log what we extracted
fmt.Fprintf(os.Stderr, "[stop] Transcript path: %s\n", input.TranscriptPath)
fmt.Fprintf(os.Stderr, "[stop] Last user message length: %d\n", len(lastUser))
fmt.Fprintf(os.Stderr, "[stop] Last assistant message length: %d\n", len(lastAssistant))
if len(lastAssistant) > 0 {
preview := lastAssistant
if len(preview) > 300 {
preview = preview[:300] + "..."
// Truncate messages to avoid sending excessive data to the worker
if len(lastAssistant) > 10000 {
lastAssistant = lastAssistant[:10000]
}
if len(lastUser) > 5000 {
lastUser = lastUser[:5000]
}
if debug {
fmt.Fprintf(os.Stderr, "[stop] Transcript path: %s\n", input.TranscriptPath)
fmt.Fprintf(os.Stderr, "[stop] Last user message length: %d\n", len(lastUser))
fmt.Fprintf(os.Stderr, "[stop] Last assistant message length: %d\n", len(lastAssistant))
if len(lastAssistant) > 0 {
preview := lastAssistant
if len(preview) > 300 {
preview = preview[:300] + "..."
}
fmt.Fprintf(os.Stderr, "[stop] Last assistant preview: %s\n", preview)
}
fmt.Fprintf(os.Stderr, "[stop] Last assistant preview: %s\n", preview)
fmt.Fprintf(os.Stderr, "[stop] Requesting summary for session %d (transcript: %v)\n", int64(sessionID), input.TranscriptPath != "")
}
// Check deadline before expensive summary request
if deadline.Err() != nil {
fmt.Fprintf(os.Stderr, "[stop] Returning early due to time limit\n")
return "", nil
}
fmt.Fprintf(os.Stderr, "[stop] Requesting summary for session %d (transcript: %v)\n", int64(sessionID), input.TranscriptPath != "")
// Request summary with message context from transcript
_, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/summarize", int64(sessionID)), map[string]interface{}{
+4
View File
@@ -16,6 +16,10 @@ type Input struct {
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("SubagentStop", true)
return
}
hooks.RunHook("SubagentStop", handleSubagentStop)
}
+122 -43
View File
@@ -5,8 +5,12 @@ import (
"fmt"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
"github.com/lukaszraczylo/claude-mnemonic/pkg/sanitize"
)
// Input is the hook input from Claude Code.
@@ -15,31 +19,117 @@ type Input struct {
Prompt string `json:"prompt"`
}
// estimateTokens provides a more accurate token count estimate.
// Uses word count * 1.3 as base, with adjustments for code and non-ASCII.
func estimateTokens(s string) int {
if len(s) == 0 {
return 0
}
// Count words (split on whitespace)
words := len(strings.Fields(s))
if words == 0 {
// No whitespace = probably a single token or code blob
return (len(s) + 3) / 4
}
// Base estimate: ~1.3 tokens per word for English text
estimate := int(float64(words) * 1.3)
// Detect code-heavy content (high non-alpha ratio)
nonAlpha := 0
nonASCII := 0
for _, r := range s {
if r > 127 {
nonASCII++
} else if !('a' <= r && r <= 'z') && !('A' <= r && r <= 'Z') && !('0' <= r && r <= '9') && r != ' ' {
nonAlpha++
}
}
totalChars := len(s)
// Code adjustment: more special chars = more tokens per word
if totalChars > 0 && float64(nonAlpha)/float64(totalChars) > 0.15 {
estimate = int(float64(estimate) * 1.3)
}
// Non-ASCII adjustment: CJK and other scripts use more tokens
if totalChars > 0 && float64(nonASCII)/float64(totalChars) > 0.1 {
estimate += nonASCII // Roughly 1 extra token per non-ASCII char
}
return estimate
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("UserPromptSubmit", true)
return
}
hooks.RunHook("UserPromptSubmit", handleUserPrompt)
}
func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
// Search for relevant observations based on the prompt
deadline, cancel := hooks.HookDeadline(10 * time.Second)
defer cancel()
searchURL := fmt.Sprintf("/api/context/search?project=%s&query=%s&cwd=%s",
url.QueryEscape(ctx.Project),
url.QueryEscape(input.Prompt),
url.QueryEscape(ctx.CWD))
var contextToInject string
var observationCount int
// Run search and session init concurrently.
// Session init doesn't strictly depend on search results -- the observation
// count passed is approximate (0) and acceptable.
var (
wg sync.WaitGroup
searchResult map[string]interface{}
initResult map[string]interface{}
initErr error
contextToInject string
observationCount int
)
searchResult, _ := hooks.GET(ctx.Port, searchURL)
// Start search in background
wg.Add(1)
go func() {
defer wg.Done()
searchResult, _ = hooks.GET(ctx.Port, searchURL)
}()
// Start session init in parallel (with observationCount=0; approximate is fine)
wg.Add(1)
go func() {
defer wg.Done()
initResult, initErr = hooks.POST(ctx.Port, "/api/sessions/init", map[string]interface{}{
"claudeSessionId": ctx.SessionID,
"project": ctx.Project,
"prompt": input.Prompt,
"matchedObservations": 0,
})
}()
// Wait for both to complete
wg.Wait()
// Check deadline after network calls
if deadline.Err() != nil {
return "", nil
}
// Process search results
if observations, ok := searchResult["observations"].([]interface{}); ok && len(observations) > 0 {
// Results are already filtered by relevance threshold and capped by max_results
// from the server-side config (ContextRelevanceThreshold, ContextMaxPromptResults)
observationCount = len(observations)
// Build context from search results
// Token budget for prompt context injection
maxTokens := 8000
currentTokens := 0
header := "<relevant-memory>\n# Relevant Knowledge From Previous Sessions\nIMPORTANT: Use this information to answer the question directly. Do NOT explore the codebase if the answer is here.\n\n"
currentTokens += estimateTokens(header)
var contextBuilder string
contextBuilder = "<relevant-memory>\n"
contextBuilder += "# Relevant Knowledge From Previous Sessions\n"
contextBuilder += "IMPORTANT: Use this information to answer the question directly. Do NOT explore the codebase if the answer is here.\n\n"
contextBuilder = header
for i, obs := range observations {
if obsMap, ok := obs.(map[string]interface{}); ok {
@@ -52,24 +142,30 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
obsType = t
}
// Start observation block
contextBuilder += fmt.Sprintf("## %d. [%s] %s\n", i+1, obsType, title)
var obsText string
obsText = fmt.Sprintf("## %d. [%s] %s\n", i+1, obsType, title)
// Add facts first (most concise answers)
if facts, ok := obsMap["facts"].([]interface{}); ok && len(facts) > 0 {
contextBuilder += "Key facts:\n"
obsText += "Key facts:\n"
for _, fact := range facts {
if factStr, ok := fact.(string); ok {
contextBuilder += fmt.Sprintf("- %s\n", factStr)
obsText += fmt.Sprintf("- %s\n", sanitize.StripSystemXML(factStr))
}
}
contextBuilder += "\n"
obsText += "\n"
}
// Add narrative if present
if narrative, ok := obsMap["narrative"].(string); ok && narrative != "" {
contextBuilder += narrative + "\n\n"
obsText += sanitize.StripSystemXML(narrative) + "\n\n"
}
obsTokens := estimateTokens(obsText)
if currentTokens+obsTokens > maxTokens {
break
}
contextBuilder += obsText
currentTokens += obsTokens
}
}
@@ -77,40 +173,24 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
contextToInject = contextBuilder
}
// Initialize session with matched observations count
result, err := hooks.POST(ctx.Port, "/api/sessions/init", map[string]interface{}{
"claudeSessionId": ctx.SessionID,
"project": ctx.Project,
"prompt": input.Prompt,
"matchedObservations": observationCount,
})
if err != nil {
return "", err
// Check session init result
if initErr != nil {
return "", initErr
}
// Check if skipped due to privacy
if skipped, ok := result["skipped"].(bool); ok && skipped {
if skipped, ok := initResult["skipped"].(bool); ok && skipped {
fmt.Fprintf(os.Stderr, "[user-prompt] Session skipped (private)\n")
return "", nil
}
// Safely extract session ID and prompt number with type checking
sessionDbIdRaw, ok := result["sessionDbId"].(float64)
if !ok {
return "", fmt.Errorf("invalid or missing sessionDbId in response")
}
sessionID := int64(sessionDbIdRaw)
promptNumberRaw, ok := result["promptNumber"].(float64)
if !ok {
return "", fmt.Errorf("invalid or missing promptNumber in response")
}
promptNumber := int(promptNumberRaw)
sessionID := int64(initResult["sessionDbId"].(float64))
promptNumber := int(initResult["promptNumber"].(float64))
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
// Start SDK agent
_, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
// Start SDK agent (depends on session init result, so kept sequential)
_, err := hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
"userPrompt": input.Prompt,
"promptNumber": promptNumber,
})
@@ -120,7 +200,6 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
// Return context if we found relevant observations
if observationCount > 0 {
// Show match count to user via stderr
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Found %d relevant memories for this prompt\n", observationCount)
return contextToInject, nil
}
+13 -84
View File
@@ -4,20 +4,16 @@ package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
"github.com/lukaszraczylo/claude-mnemonic/internal/mcp"
"github.com/lukaszraczylo/claude-mnemonic/internal/scoring"
"github.com/lukaszraczylo/claude-mnemonic/internal/search"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/internal/watcher"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -28,7 +24,6 @@ var Version = "dev"
func main() {
// Parse flags
project := flag.String("project", "", "Project name (required)")
dataDir := flag.String("data-dir", "", "Data directory (default: ~/.claude-mnemonic)")
debug := flag.Bool("debug", false, "Enable debug logging")
flag.Parse()
@@ -43,23 +38,12 @@ func main() {
log.Fatal().Msg("--project is required")
}
// Ensure data directory and settings exist
if err := config.EnsureAll(); err != nil {
log.Fatal().Err(err).Msg("Failed to ensure data directories")
}
// Get worker port from config
port := config.GetWorkerPort()
workerURL := fmt.Sprintf("http://localhost:%d", port)
// Load config
cfg, err := config.Load()
if err != nil {
log.Warn().Err(err).Msg("Failed to load config, using defaults")
cfg = config.Default()
}
// Override data directory if specified
dbPath := cfg.DBPath
if *dataDir != "" {
dbPath = *dataDir + "/claude-mnemonic.db"
}
// Create HTTP client for worker
client := &http.Client{Timeout: 30 * time.Second}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -73,67 +57,12 @@ func main() {
cancel()
}()
// Initialize database store (migrations run automatically)
storeCfg := gorm.Config{
Path: dbPath,
MaxConns: cfg.MaxConns,
// WALMode is enabled automatically by GORM
}
store, err := gorm.NewStore(storeCfg)
if err != nil {
log.Fatal().Err(err).Msg("Failed to initialize database store")
}
defer store.Close()
// Start file watchers for config changes
startWatchers()
// Initialize stores
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
summaryStore := gorm.NewSummaryStore(store)
promptStore := gorm.NewPromptStore(store, nil)
patternStore := gorm.NewPatternStore(store)
relationStore := gorm.NewRelationStore(store)
sessionStore := gorm.NewSessionStore(store)
// Initialize embedding service and vector client
var vectorClient *sqlitevec.Client
embedSvc, err := embedding.NewService()
if err != nil {
log.Warn().Err(err).Msg("Embedding service unavailable, vector search disabled")
} else {
defer embedSvc.Close()
vectorClient, err = sqlitevec.NewClient(sqlitevec.Config{DB: store.GetRawDB()}, embedSvc)
if err != nil {
log.Warn().Err(err).Msg("Vector client unavailable, vector search disabled")
} else {
log.Info().Msg("Vector search enabled via sqlite-vec")
}
}
// Initialize scoring components
scoreConfig := models.DefaultScoringConfig()
scoreCalculator := scoring.NewCalculator(scoreConfig)
recalculator := scoring.NewRecalculator(observationStore, scoreCalculator, log.Logger)
go recalculator.Start(ctx)
defer recalculator.Stop()
// Initialize search manager
searchMgr := search.NewManager(observationStore, summaryStore, promptStore, vectorClient)
// Start file watchers
startWatchers(ctx, dbPath)
// Create and run MCP server with all dependencies
server := mcp.NewServer(
searchMgr,
Version,
observationStore,
patternStore,
relationStore,
sessionStore,
vectorClient,
scoreCalculator,
recalculator,
)
log.Info().Str("project", *project).Str("version", Version).Msg("Starting MCP server")
// Create and run MCP server
server := mcp.NewServer(client, workerURL, *project, Version)
log.Info().Str("project", *project).Str("version", Version).Str("worker", workerURL).Msg("Starting MCP server")
if err := server.Run(ctx); err != nil {
log.Fatal().Err(err).Msg("MCP server error")
@@ -141,7 +70,7 @@ func main() {
}
// startWatchers initializes file watchers for config.
func startWatchers(ctx context.Context, dbPath string) {
func startWatchers() {
// Watch config file for changes (triggers process exit for restart)
configPath := config.SettingsPath()
configWatcher, err := watcher.New(configPath, func() {
+8 -7
View File
@@ -7,15 +7,16 @@ replace github.com/sugarme/tokenizer => github.com/clems4ever/tokenizer v0.0.0-2
require (
github.com/asg017/sqlite-vec-go-bindings v0.1.6
github.com/fsnotify/fsnotify v1.9.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/chi/v5 v5.2.5
github.com/go-gormigrate/gormigrate/v2 v2.1.5
github.com/goccy/go-json v0.10.5
github.com/mattn/go-sqlite3 v1.14.33
github.com/rs/zerolog v1.34.0
github.com/goccy/go-json v0.10.6
github.com/mattn/go-sqlite3 v1.14.37
github.com/rs/zerolog v1.35.0
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
github.com/stretchr/testify v1.11.1
github.com/sugarme/tokenizer v0.3.0
github.com/yalue/onnxruntime_go v1.25.0
github.com/yalue/onnxruntime_go v1.27.0
golang.org/x/sync v0.20.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
@@ -33,7 +34,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/schollz/progressbar/v2 v2.15.0 // indirect
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+16 -23
View File
@@ -2,7 +2,6 @@ github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI
github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q=
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43 h1:j8YQypEqa5OjqbGciCNb9hOcYbo1oTVuEjd/iu9U2SY=
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43/go.mod h1:VJ+DLK5ZEZwzvODOWwY0cw+B1dabTd3nCB5HuFCItCc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -10,38 +9,32 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
@@ -52,15 +45,15 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
github.com/yalue/onnxruntime_go v1.25.0 h1:nlhVau1BpLZ/BYr+WpPZCJRD/WES0qo6dK7aKyyAs3g=
github.com/yalue/onnxruntime_go v1.25.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+298
View File
@@ -0,0 +1,298 @@
package python
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
)
// =============================================================================
// TEST HELPERS
// =============================================================================
func createTempPythonFile(t *testing.T, content string) string {
t.Helper()
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.py")
err := os.WriteFile(filePath, []byte(content), 0600)
require.NoError(t, err)
return filePath
}
// =============================================================================
// TESTS FOR Chunker
// =============================================================================
func TestNewChunker(t *testing.T) {
t.Parallel()
opts := chunking.DefaultChunkOptions()
c := NewChunker(opts)
assert.NotNil(t, c)
assert.NotNil(t, c.parser)
}
func TestChunker_Language(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
assert.Equal(t, chunking.LanguagePython, c.Language())
}
func TestChunker_SupportedExtensions(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
exts := c.SupportedExtensions()
assert.Contains(t, exts, ".py")
}
func TestChunker_Chunk_SimpleFunction(t *testing.T) {
t.Parallel()
code := `def greet(name):
"""Greets a person by name."""
return f"Hello, {name}!"
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the greet function
var foundGreet bool
for _, chunk := range chunks {
if chunk.Name == "greet" {
foundGreet = true
assert.Equal(t, chunking.ChunkTypeFunction, chunk.Type)
assert.Equal(t, chunking.LanguagePython, chunk.Language)
assert.Contains(t, chunk.Content, "def greet")
}
}
assert.True(t, foundGreet, "Should find 'greet' function")
}
func TestChunker_Chunk_ClassWithMethods(t *testing.T) {
t.Parallel()
code := `class Calculator:
"""A simple calculator class."""
def add(self, a, b):
"""Adds two numbers."""
return a + b
def multiply(self, a, b):
"""Multiplies two numbers."""
return a * b
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Calculator class and its methods
var foundClass, foundAdd, foundMultiply bool
for _, chunk := range chunks {
switch chunk.Name {
case "Calculator":
foundClass = true
assert.Equal(t, chunking.ChunkTypeClass, chunk.Type)
case "add":
foundAdd = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
case "multiply":
foundMultiply = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
}
}
assert.True(t, foundClass, "Should find 'Calculator' class")
assert.True(t, foundAdd, "Should find 'add' method")
assert.True(t, foundMultiply, "Should find 'multiply' method")
}
func TestChunker_Chunk_MultipleFunctions(t *testing.T) {
t.Parallel()
code := `def first_function():
pass
def second_function(x, y):
return x + y
def third_function():
"""Has a docstring."""
return 42
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Should find all three functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["first_function"])
assert.True(t, functionNames["second_function"])
assert.True(t, functionNames["third_function"])
}
func TestChunker_Chunk_FileNotFound(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), "/nonexistent/path/file.py")
require.Error(t, err)
assert.Contains(t, err.Error(), "read file")
}
func TestChunker_Chunk_EmptyFile(t *testing.T) {
t.Parallel()
filePath := createTempPythonFile(t, "")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
assert.Empty(t, chunks)
}
func TestChunker_Chunk_OnlyComments(t *testing.T) {
t.Parallel()
code := `# This is a comment
# Another comment
"""
This is a module docstring
"""
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Comments and docstrings without code should not produce chunks
assert.Empty(t, chunks)
}
func TestChunker_Chunk_NestedClass(t *testing.T) {
t.Parallel()
code := `class Outer:
class Inner:
def inner_method(self):
pass
def outer_method(self):
pass
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Outer class at minimum
var foundOuter bool
for _, chunk := range chunks {
if chunk.Name == "Outer" {
foundOuter = true
}
}
assert.True(t, foundOuter, "Should find 'Outer' class")
}
func TestChunker_Chunk_Decorators(t *testing.T) {
t.Parallel()
code := `@staticmethod
def static_func():
pass
@classmethod
def class_func(cls):
pass
@property
def my_property(self):
return self._value
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find decorated functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
functionNames[chunk.Name] = true
}
assert.True(t, functionNames["static_func"])
assert.True(t, functionNames["class_func"])
assert.True(t, functionNames["my_property"])
}
func TestChunker_Chunk_AsyncFunction(t *testing.T) {
t.Parallel()
code := `async def fetch_data(url):
"""Fetches data from URL asynchronously."""
pass
async def process_items(items):
for item in items:
await process(item)
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find async functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
functionNames[chunk.Name] = true
}
assert.True(t, functionNames["fetch_data"])
assert.True(t, functionNames["process_items"])
}
+213
View File
@@ -0,0 +1,213 @@
package chunking
import (
"testing"
"github.com/stretchr/testify/assert"
)
// =============================================================================
// TESTS FOR Chunk METHODS
// =============================================================================
func TestChunk_Identifier(t *testing.T) {
tests := []struct {
name string
expected string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "top-level function",
chunk: Chunk{
Name: "MyFunction",
ParentName: "",
},
expected: "MyFunction",
},
{
name: "method with parent",
chunk: Chunk{
Name: "Process",
ParentName: "Handler",
},
expected: "Handler.Process",
},
{
name: "nested method",
chunk: Chunk{
Name: "Validate",
ParentName: "UserService",
},
expected: "UserService.Validate",
},
// ===== EDGE CASES =====
{
name: "empty name",
chunk: Chunk{
Name: "",
ParentName: "",
},
expected: "",
},
{
name: "parent but no name",
chunk: Chunk{
Name: "",
ParentName: "Parent",
},
expected: "Parent.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.Identifier()
assert.Equal(t, tt.expected, result)
})
}
}
func TestChunk_LineRange(t *testing.T) {
tests := []struct {
name string
expected string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "single line",
chunk: Chunk{
StartLine: 10,
EndLine: 10,
},
expected: "L10-L10",
},
{
name: "multi-line",
chunk: Chunk{
StartLine: 25,
EndLine: 50,
},
expected: "L25-L50",
},
// ===== EDGE CASES =====
{
name: "line 1",
chunk: Chunk{
StartLine: 1,
EndLine: 5,
},
expected: "L1-L5",
},
{
name: "large line numbers",
chunk: Chunk{
StartLine: 1000,
EndLine: 2500,
},
expected: "L1000-L2500",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.LineRange()
assert.Equal(t, tt.expected, result)
})
}
}
func TestChunk_SearchableContent(t *testing.T) {
tests := []struct {
name string
contains []string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "full chunk with all fields",
chunk: Chunk{
Signature: "func ProcessData(input []byte) error",
DocComment: "// ProcessData handles incoming data",
Content: "func ProcessData(input []byte) error {\n\treturn nil\n}",
},
contains: []string{
"func ProcessData(input []byte) error",
"ProcessData handles incoming data",
"return nil",
},
},
{
name: "only signature",
chunk: Chunk{
Signature: "func Hello()",
},
contains: []string{"func Hello()"},
},
{
name: "only content",
chunk: Chunk{
Content: "some code here",
},
contains: []string{"some code here"},
},
// ===== EDGE CASES =====
{
name: "empty chunk",
chunk: Chunk{},
contains: []string{},
},
{
name: "only doc comment",
chunk: Chunk{
DocComment: "// Important documentation",
},
contains: []string{"Important documentation"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.SearchableContent()
for _, expected := range tt.contains {
assert.Contains(t, result, expected)
}
})
}
}
func TestDefaultChunkOptions(t *testing.T) {
opts := DefaultChunkOptions()
assert.Greater(t, opts.MaxChunkSize, 0, "MaxChunkSize should be positive")
assert.True(t, opts.IncludeDocComments, "IncludeDocComments should be true by default")
assert.True(t, opts.IncludePrivate, "IncludePrivate should be true by default")
assert.Equal(t, 0, opts.MinLines, "MinLines should be 0 by default")
}
// =============================================================================
// TESTS FOR ChunkType AND Language CONSTANTS
// =============================================================================
func TestChunkType_Values(t *testing.T) {
// Ensure all chunk types have expected values
assert.Equal(t, ChunkType("function"), ChunkTypeFunction)
assert.Equal(t, ChunkType("method"), ChunkTypeMethod)
assert.Equal(t, ChunkType("class"), ChunkTypeClass)
assert.Equal(t, ChunkType("interface"), ChunkTypeInterface)
assert.Equal(t, ChunkType("type"), ChunkTypeType)
assert.Equal(t, ChunkType("const"), ChunkTypeConst)
assert.Equal(t, ChunkType("var"), ChunkTypeVar)
}
func TestLanguage_Values(t *testing.T) {
// Ensure all language types have expected values
assert.Equal(t, Language("go"), LanguageGo)
assert.Equal(t, Language("python"), LanguagePython)
assert.Equal(t, Language("typescript"), LanguageTypeScript)
assert.Equal(t, Language("javascript"), LanguageJavaScript)
}
@@ -0,0 +1,398 @@
package typescript
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
)
// =============================================================================
// TEST HELPERS
// =============================================================================
func createTempTSFile(t *testing.T, content string, ext string) string {
t.Helper()
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test"+ext)
err := os.WriteFile(filePath, []byte(content), 0600)
require.NoError(t, err)
return filePath
}
// =============================================================================
// TESTS FOR Chunker
// =============================================================================
func TestNewChunker(t *testing.T) {
t.Parallel()
opts := chunking.DefaultChunkOptions()
c := NewChunker(opts)
assert.NotNil(t, c)
assert.NotNil(t, c.parser)
}
func TestChunker_Language(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
assert.Equal(t, chunking.LanguageTypeScript, c.Language())
}
func TestChunker_SupportedExtensions(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
exts := c.SupportedExtensions()
assert.Contains(t, exts, ".ts")
assert.Contains(t, exts, ".tsx")
assert.Contains(t, exts, ".js")
assert.Contains(t, exts, ".jsx")
}
func TestChunker_Chunk_SimpleFunction(t *testing.T) {
t.Parallel()
code := `function greet(name: string): string {
return "Hello, " + name + "!";
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the greet function
var foundGreet bool
for _, chunk := range chunks {
if chunk.Name == "greet" {
foundGreet = true
assert.Equal(t, chunking.ChunkTypeFunction, chunk.Type)
assert.Equal(t, chunking.LanguageTypeScript, chunk.Language)
assert.Contains(t, chunk.Content, "function greet")
}
}
assert.True(t, foundGreet, "Should find 'greet' function")
}
func TestChunker_Chunk_ClassWithMethods(t *testing.T) {
t.Parallel()
code := `class Calculator {
add(a: number, b: number): number {
return a + b;
}
multiply(a: number, b: number): number {
return a * b;
}
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Calculator class and its methods
var foundClass, foundAdd, foundMultiply bool
for _, chunk := range chunks {
switch chunk.Name {
case "Calculator":
foundClass = true
assert.Equal(t, chunking.ChunkTypeClass, chunk.Type)
case "add":
foundAdd = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
case "multiply":
foundMultiply = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
}
}
assert.True(t, foundClass, "Should find 'Calculator' class")
assert.True(t, foundAdd, "Should find 'add' method")
assert.True(t, foundMultiply, "Should find 'multiply' method")
}
func TestChunker_Chunk_Interface(t *testing.T) {
t.Parallel()
code := `interface User {
id: number;
name: string;
email: string;
}
interface Authenticator {
login(username: string, password: string): boolean;
logout(): void;
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find interfaces
interfaceNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeInterface {
interfaceNames[chunk.Name] = true
}
}
assert.True(t, interfaceNames["User"])
assert.True(t, interfaceNames["Authenticator"])
}
func TestChunker_Chunk_TypeAlias(t *testing.T) {
t.Parallel()
code := `type UserID = string;
type Handler = (event: Event) => void;
type Result<T> = { success: true; data: T } | { success: false; error: Error };
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find type aliases
typeNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeType {
typeNames[chunk.Name] = true
}
}
assert.True(t, typeNames["UserID"])
assert.True(t, typeNames["Handler"])
assert.True(t, typeNames["Result"])
}
func TestChunker_Chunk_ArrowFunction(t *testing.T) {
t.Parallel()
code := `const add = (a: number, b: number): number => a + b;
const greet = (name: string): string => {
return "Hello, " + name;
};
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Arrow functions may or may not be captured depending on AST structure
// At minimum, no error should occur
}
func TestChunker_Chunk_FileNotFound(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), "/nonexistent/path/file.ts")
require.Error(t, err)
assert.Contains(t, err.Error(), "read file")
}
func TestChunker_Chunk_EmptyFile(t *testing.T) {
t.Parallel()
filePath := createTempTSFile(t, "", ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
assert.Empty(t, chunks)
}
func TestChunker_Chunk_OnlyComments(t *testing.T) {
t.Parallel()
code := `// This is a comment
/* Another comment */
/**
* JSDoc comment
*/
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Comments without code should not produce chunks
assert.Empty(t, chunks)
}
func TestChunker_Chunk_AsyncFunction(t *testing.T) {
t.Parallel()
code := `async function fetchData(url: string): Promise<any> {
const response = await fetch(url);
return response.json();
}
async function processItems(items: string[]): Promise<void> {
for (const item of items) {
await process(item);
}
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find async functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["fetchData"])
assert.True(t, functionNames["processItems"])
}
func TestChunker_Chunk_ExportedFunction(t *testing.T) {
t.Parallel()
code := `export function publicFunction(): void {
console.log("public");
}
export default function defaultExport(): void {
console.log("default");
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find exported functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["publicFunction"])
assert.True(t, functionNames["defaultExport"])
}
func TestChunker_Chunk_JSXFile(t *testing.T) {
t.Parallel()
code := `function Button({ label }: { label: string }) {
return <button>{label}</button>;
}
function App() {
return (
<div>
<Button label="Click me" />
</div>
);
}
`
filePath := createTempTSFile(t, code, ".tsx")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find JSX components as functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["Button"])
assert.True(t, functionNames["App"])
}
func TestChunker_Chunk_JavaScript(t *testing.T) {
t.Parallel()
code := `function simpleFunc() {
return 42;
}
class MyClass {
constructor() {
this.value = 0;
}
getValue() {
return this.value;
}
}
`
filePath := createTempTSFile(t, code, ".js")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find JavaScript functions and classes
var foundFunc, foundClass bool
for _, chunk := range chunks {
if chunk.Name == "simpleFunc" {
foundFunc = true
}
if chunk.Name == "MyClass" {
foundClass = true
}
}
assert.True(t, foundFunc, "Should find 'simpleFunc' function")
assert.True(t, foundClass, "Should find 'MyClass' class")
}
+50 -9
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
@@ -46,28 +47,36 @@ type Config struct {
VectorStorageStrategy string `json:"vector_storage_strategy"`
ContextObsConcepts []string `json:"context_obs_concepts"`
ContextObsTypes []string `json:"context_obs_types"`
ContextMaxPromptResults int `json:"context_max_prompt_results"`
RerankingResults int `json:"reranking_results"`
ContextFullCount int `json:"context_full_count"`
GraphBranchFactor int `json:"graph_branch_factor"`
GraphEdgeWeight float64 `json:"graph_edge_weight"`
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
RerankingCandidates int `json:"reranking_candidates"`
WorkerPort int `json:"worker_port"`
DeduplicationThreshold float64 `json:"deduplication_threshold"`
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
ContextObservations int `json:"context_observations"`
ContextFullCount int `json:"context_full_count"`
ContextMaxPromptResults int `json:"context_max_prompt_results"`
ContextSessionCount int `json:"context_session_count"`
MaxConns int `json:"max_conns"`
RerankingAlpha float64 `json:"reranking_alpha"`
GraphMaxHops int `json:"graph_max_hops"`
GraphBranchFactor int `json:"graph_branch_factor"`
RerankingResults int `json:"reranking_results"`
GraphRebuildIntervalMin int `json:"graph_rebuild_interval_min"`
HubThreshold int `json:"hub_threshold"`
ContextShowLastSummary bool `json:"context_show_last_summary"`
RerankingEnabled bool `json:"reranking_enabled"`
ObservationRetentionDays int `json:"observation_retention_days"`
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
ContextMaxTokensStartup int `json:"context_max_tokens_startup"`
ContextMaxTokensPrompt int `json:"context_max_tokens_prompt"`
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
ContextShowReadTokens bool `json:"context_show_read_tokens"`
RerankingPureMode bool `json:"reranking_pure_mode"`
GraphEnabled bool `json:"graph_enabled"`
DeduplicationEnabled bool `json:"deduplication_enabled"`
MaintenanceEnabled bool `json:"maintenance_enabled"`
RerankingEnabled bool `json:"reranking_enabled"`
ContextShowLastSummary bool `json:"context_show_last_summary"`
CleanupStaleObservations bool `json:"cleanup_stale_observations"`
}
var (
@@ -93,8 +102,9 @@ func SettingsPath() string {
}
// EnsureDataDir creates the data directory if it doesn't exist.
// Uses 0700 permissions (owner-only) for security.
func EnsureDataDir() error {
return os.MkdirAll(DataDir(), 0750)
return os.MkdirAll(DataDir(), 0700)
}
// EnsureSettings creates a default settings file if it doesn't exist.
@@ -161,8 +171,16 @@ func Default() *Config {
ContextShowLastSummary: true,
ContextObsTypes: DefaultObservationTypes,
ContextObsConcepts: DefaultObservationConcepts,
ContextRelevanceThreshold: 0.3, // Minimum 30% similarity to include
ContextMaxPromptResults: 10, // Cap at 10 results max (0 = no cap, threshold only)
ContextRelevanceThreshold: 0.3, // Minimum 30% similarity to include
ContextMaxPromptResults: 10, // Cap at 10 results max (0 = no cap, threshold only)
ContextMaxTokensStartup: 16000, // Max tokens for SessionStart context injection
ContextMaxTokensPrompt: 8000, // Max tokens for UserPromptSubmit context injection
DeduplicationEnabled: true, // Enable write-time vector dedup
DeduplicationThreshold: 0.9, // Similarity threshold for merging (0.9 = very similar)
MaintenanceEnabled: true, // Enable scheduled maintenance
MaintenanceIntervalHours: 6, // Run every 6 hours
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
CleanupStaleObservations: false, // Don't auto-cleanup stale observations
}
}
@@ -260,6 +278,29 @@ func Load() (*Config, error) {
if v, ok := settings["CLAUDE_MNEMONIC_HUB_THRESHOLD"].(float64); ok && v > 0 {
cfg.HubThreshold = int(v)
}
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_STARTUP"].(float64); ok && v > 0 {
cfg.ContextMaxTokensStartup = int(v)
}
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_PROMPT"].(float64); ok && v > 0 {
cfg.ContextMaxTokensPrompt = int(v)
}
// Deduplication settings
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_ENABLED"].(bool); ok {
cfg.DeduplicationEnabled = v
}
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_THRESHOLD"].(float64); ok && v > 0 && v <= 1 {
cfg.DeduplicationThreshold = v
}
// Also support env vars for dedup settings
if v := os.Getenv("CLAUDE_MNEMONIC_DEDUP_ENABLED"); v != "" {
cfg.DeduplicationEnabled = v == "true" || v == "1"
}
if v := os.Getenv("CLAUDE_MNEMONIC_DEDUP_THRESHOLD"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 && f <= 1 {
cfg.DeduplicationThreshold = f
}
}
return cfg, nil
}
+54 -17
View File
@@ -9,27 +9,13 @@ import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// EnsureSessionExists creates a session if it doesn't exist.
// Uses INSERT OR IGNORE pattern for atomic idempotent creation (single query instead of COUNT + INSERT).
// This is shared between stores to avoid duplication.
func EnsureSessionExists(ctx context.Context, db *gorm.DB, sdkSessionID, project string) error {
// Check if session exists
var count int64
err := db.WithContext(ctx).
Model(&SDKSession{}).
Where("sdk_session_id = ?", sdkSessionID).
Count(&count).Error
if err != nil {
return err
}
if count > 0 {
return nil // Session exists
}
// Auto-create session
now := time.Now()
session := &SDKSession{
ClaudeSessionID: sdkSessionID,
@@ -41,7 +27,14 @@ func EnsureSessionExists(ctx context.Context, db *gorm.DB, sdkSessionID, project
PromptCounter: 0,
}
return db.WithContext(ctx).Create(session).Error
// Use INSERT OR IGNORE - single query, atomic operation
// If session already exists (conflict on sdk_session_id), do nothing
return db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sdk_session_id"}},
DoNothing: true,
}).
Create(session).Error
}
// sqlNullString creates a sql.NullString from a string.
@@ -52,8 +45,13 @@ func sqlNullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: true}
}
// MaxPaginationLimit is the maximum allowed limit for pagination queries.
// This protects against resource exhaustion from excessively large requests.
const MaxPaginationLimit = 1000
// ParseLimitParam parses the "limit" query parameter from an HTTP request.
// Returns defaultLimit if the parameter is missing or invalid.
// Note: This does NOT enforce a maximum limit. Use ParseLimitParamWithMax for that.
func ParseLimitParam(r *http.Request, defaultLimit int) int {
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
@@ -62,3 +60,42 @@ func ParseLimitParam(r *http.Request, defaultLimit int) int {
}
return defaultLimit
}
// ParseLimitParamWithMax parses the "limit" query parameter with a maximum cap.
// Returns min(parsed, maxLimit) or defaultLimit if missing/invalid.
// If maxLimit is 0, uses MaxPaginationLimit (1000).
func ParseLimitParamWithMax(r *http.Request, defaultLimit, maxLimit int) int {
if maxLimit <= 0 {
maxLimit = MaxPaginationLimit
}
limit := ParseLimitParam(r, defaultLimit)
if limit > maxLimit {
return maxLimit
}
return limit
}
// ParseOffsetParam parses the "offset" query parameter from an HTTP request.
// Returns 0 if the parameter is missing or invalid.
func ParseOffsetParam(r *http.Request) int {
if o := r.URL.Query().Get("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
return parsed
}
}
return 0
}
// PaginationParams holds pagination parameters.
type PaginationParams struct {
Limit int
Offset int
}
// ParsePaginationParams parses both limit and offset from an HTTP request.
func ParsePaginationParams(r *http.Request, defaultLimit int) PaginationParams {
return PaginationParams{
Limit: ParseLimitParam(r, defaultLimit),
Offset: ParseOffsetParam(r),
}
}
+238
View File
@@ -322,6 +322,244 @@ func runMigrations(db *gorm.DB, sqlDB *sql.DB) error {
return tx.Migrator().DropTable("observation_relations")
},
},
// Migration 012: Query optimization indexes
// Adds covering and composite indexes for common query patterns
{
ID: "012_query_optimization_indexes",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Composite index for observation queries by project + scope + importance
// Covers the common pattern: WHERE project = ? OR scope = 'global' ORDER BY importance_score DESC
`CREATE INDEX IF NOT EXISTS idx_observations_project_scope_importance
ON observations(project, scope, importance_score DESC, created_at_epoch DESC)`,
// Covering index for observation retrieval (includes most used columns)
// Allows index-only scans for listing queries
`CREATE INDEX IF NOT EXISTS idx_observations_project_covering
ON observations(project, scope, is_superseded, importance_score DESC)
WHERE is_superseded = 0 OR is_superseded IS NULL`,
// Index for session summary lookups
`CREATE INDEX IF NOT EXISTS idx_summaries_project_importance
ON session_summaries(project, importance_score DESC, created_at_epoch DESC)`,
// Index for prompt retrieval by session
`CREATE INDEX IF NOT EXISTS idx_prompts_session_number
ON user_prompts(claude_session_id, prompt_number)`,
// Index for pattern queries by frequency
`CREATE INDEX IF NOT EXISTS idx_patterns_frequency
ON patterns(frequency DESC, last_seen_at_epoch DESC)
WHERE is_deprecated = 0`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: index may already exist or fail for benign reasons
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
sqls := []string{
"DROP INDEX IF EXISTS idx_observations_project_scope_importance",
"DROP INDEX IF EXISTS idx_observations_project_covering",
"DROP INDEX IF EXISTS idx_summaries_project_importance",
"DROP INDEX IF EXISTS idx_prompts_session_number",
"DROP INDEX IF EXISTS idx_patterns_frequency",
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
continue
}
}
return nil
},
},
// Migration 013: Add archival columns to observations
{
ID: "013_observation_archival",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Add archival columns
`ALTER TABLE observations ADD COLUMN is_archived INTEGER DEFAULT 0`,
`ALTER TABLE observations ADD COLUMN archived_at_epoch INTEGER`,
`ALTER TABLE observations ADD COLUMN archived_reason TEXT`,
// Index for archived observations
`CREATE INDEX IF NOT EXISTS idx_observations_archived ON observations(is_archived)`,
// Composite index for filtering active (non-archived) observations
`CREATE INDEX IF NOT EXISTS idx_observations_active
ON observations(project, is_archived, is_superseded, importance_score DESC)
WHERE (is_archived = 0 OR is_archived IS NULL)`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: column may already exist
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// SQLite doesn't support DROP COLUMN in older versions
// but for newer versions we can try
sqls := []string{
"DROP INDEX IF EXISTS idx_observations_active",
"DROP INDEX IF EXISTS idx_observations_archived",
}
for _, s := range sqls {
_ = tx.Exec(s).Error
}
return nil
},
},
// Migration 014: Add performance-critical indexes for common query patterns
{
ID: "014_performance_indexes",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Index for batch ID lookups (IN queries)
`CREATE INDEX IF NOT EXISTS idx_observations_id_covering
ON observations(id, project, scope, importance_score)`,
// Index for vector search result fetching
`CREATE INDEX IF NOT EXISTS idx_vectors_doc_type_project
ON vectors(doc_type, project, scope)`,
// Index for session summaries by project
`CREATE INDEX IF NOT EXISTS idx_summaries_project_created
ON session_summaries(project, created_at_epoch DESC)`,
// Index for user prompts by session
`CREATE INDEX IF NOT EXISTS idx_prompts_session_created
ON user_prompts(claude_session_id, created_at_epoch DESC)`,
// Index for patterns by type and project
`CREATE INDEX IF NOT EXISTS idx_patterns_type_project
ON patterns(type, project, frequency DESC)
WHERE is_deprecated = 0`,
// Index for observation relations
`CREATE INDEX IF NOT EXISTS idx_relations_source_type
ON observation_relations(source_observation_id, relation_type)`,
`CREATE INDEX IF NOT EXISTS idx_relations_target_type
ON observation_relations(target_observation_id, relation_type)`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: index may already exist
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
sqls := []string{
"DROP INDEX IF EXISTS idx_observations_id_covering",
"DROP INDEX IF EXISTS idx_vectors_doc_type_project",
"DROP INDEX IF EXISTS idx_summaries_project_created",
"DROP INDEX IF EXISTS idx_prompts_session_created",
"DROP INDEX IF EXISTS idx_patterns_type_project",
"DROP INDEX IF EXISTS idx_relations_source_type",
"DROP INDEX IF EXISTS idx_relations_target_type",
}
for _, s := range sqls {
_ = tx.Exec(s).Error
}
return nil
},
},
// Migration 015: Add optimized composite indexes for common query patterns
{
ID: "015_optimized_composite_indexes",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Composite index for GetRecentObservations with project+scope filtering
// Covers: WHERE (project = ? OR scope = 'global') ORDER BY importance_score DESC, created_at_epoch DESC
`CREATE INDEX IF NOT EXISTS idx_observations_project_scope_created
ON observations(project, scope, created_at_epoch DESC, importance_score DESC)`,
// Index for scope='global' queries (common pattern in search)
`CREATE INDEX IF NOT EXISTS idx_observations_global_scope
ON observations(scope, importance_score DESC, created_at_epoch DESC)
WHERE scope = 'global'`,
// Index for vector search result deduplication by observation
`CREATE INDEX IF NOT EXISTS idx_vectors_observation_lookup
ON vectors(doc_type, sqlite_id, project)
WHERE doc_type = 'observation'`,
// Index for FTS search result ordering
`CREATE INDEX IF NOT EXISTS idx_observations_fts_ordering
ON observations(project, importance_score DESC)
WHERE (is_archived = 0 OR is_archived IS NULL) AND (is_superseded = 0 OR is_superseded IS NULL)`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: index may already exist
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
sqls := []string{
"DROP INDEX IF EXISTS idx_observations_project_scope_created",
"DROP INDEX IF EXISTS idx_observations_global_scope",
"DROP INDEX IF EXISTS idx_vectors_observation_lookup",
"DROP INDEX IF EXISTS idx_observations_fts_ordering",
}
for _, s := range sqls {
_ = tx.Exec(s).Error
}
return nil
},
},
// Migration 016: Add covering indexes for relation joins and active observations
{
ID: "016_relation_and_active_indexes",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Covering index for observation relation joins (common JOIN patterns)
// Speeds up queries like: JOIN observation_relations ON source_id = obs.id WHERE relation_type = ?
`CREATE INDEX IF NOT EXISTS idx_relations_source_type_target
ON observation_relations(source_observation_id, relation_type, target_observation_id)`,
// Covering index for reverse relation lookups
`CREATE INDEX IF NOT EXISTS idx_relations_target_type_source
ON observation_relations(target_observation_id, relation_type, source_observation_id)`,
// Partial index for active (non-archived, non-superseded) observations
// Optimizes activeObservationFilter queries
`CREATE INDEX IF NOT EXISTS idx_observations_active
ON observations(project, importance_score DESC, created_at_epoch DESC)
WHERE (is_archived = 0 OR is_archived IS NULL) AND (is_superseded = 0 OR is_superseded IS NULL)`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: index may already exist
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
sqls := []string{
"DROP INDEX IF EXISTS idx_relations_source_type_target",
"DROP INDEX IF EXISTS idx_relations_target_type_source",
"DROP INDEX IF EXISTS idx_observations_active",
}
for _, s := range sqls {
_ = tx.Exec(s).Error
}
return nil
},
},
})
if err := m.Migrate(); err != nil {
+11 -7
View File
@@ -45,30 +45,34 @@ func (s *SDKSession) BeforeCreate(tx *gorm.DB) error {
}
// Observation represents a stored observation (learning).
// Field order optimized for memory alignment (fieldalignment).
type Observation struct {
FileMtimes models.JSONInt64Map `gorm:"type:text"`
SDKSessionID string `gorm:"index;not null"`
Project string `gorm:"index;not null"`
Project string `gorm:"index:idx_observations_project;index:idx_observations_project_created,priority:1;not null"`
Scope models.ObservationScope `gorm:"type:text;default:'project';check:scope IN ('project', 'global');index:idx_observations_scope;index:idx_observations_project_scope,priority:2"`
Type models.ObservationType `gorm:"type:text;check:type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change');index;not null"`
CreatedAt string `gorm:"not null"`
Title sql.NullString `gorm:"type:text"`
Facts models.JSONStringArray `gorm:"type:text"`
Narrative sql.NullString `gorm:"type:text"`
Concepts models.JSONStringArray `gorm:"type:text"`
FilesRead models.JSONStringArray `gorm:"type:text"`
FilesModified models.JSONStringArray `gorm:"type:text"`
Subtitle sql.NullString `gorm:"type:text"`
Facts models.JSONStringArray `gorm:"type:text"`
LastRetrievedAt sql.NullInt64 `gorm:"column:last_retrieved_at_epoch"`
PromptNumber sql.NullInt64
Title sql.NullString `gorm:"type:text"`
ArchivedReason sql.NullString
ScoreUpdatedAt sql.NullInt64 `gorm:"column:score_updated_at_epoch;index:idx_observations_score_updated"`
PromptNumber sql.NullInt64
ArchivedAt sql.NullInt64 `gorm:"column:archived_at_epoch"`
LastRetrievedAt sql.NullInt64 `gorm:"column:last_retrieved_at_epoch"`
ID int64 `gorm:"primaryKey;autoIncrement"`
ImportanceScore float64 `gorm:"type:real;default:1.0;index:idx_observations_importance,priority:1,sort:desc"`
UserFeedback int `gorm:"default:0"`
RetrievalCount int `gorm:"default:0"`
CreatedAtEpoch int64 `gorm:"index:idx_observations_created,sort:desc;not null"`
CreatedAtEpoch int64 `gorm:"index:idx_observations_created,sort:desc;index:idx_observations_project_created,priority:2,sort:desc;not null"`
DiscoveryTokens int64 `gorm:"default:0"`
IsSuperseded int `gorm:"default:0;index:idx_observations_superseded,priority:1"`
IsSuperseded int `gorm:"default:0;index:idx_observations_superseded;index:idx_observations_active,priority:2"`
IsArchived int `gorm:"default:0;index:idx_observations_archived;index:idx_observations_active,priority:1"`
}
func (Observation) TableName() string { return "observations" }
+579 -39
View File
@@ -5,39 +5,126 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"gorm.io/gorm"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// MaxObservationsPerProject is the maximum number of observations to keep per project.
const MaxObservationsPerProject = 100
// cleanupQueueSize is the buffer size for the cleanup queue.
const cleanupQueueSize = 100
// commonWords is a package-level set for O(1) lookup of stop words.
// Created once at init time to avoid repeated map allocations.
var commonWords = map[string]struct{}{
"the": {}, "and": {}, "or": {}, "but": {}, "in": {},
"on": {}, "at": {}, "to": {}, "for": {}, "of": {},
"with": {}, "by": {}, "from": {}, "as": {}, "is": {},
"was": {}, "are": {}, "were": {}, "be": {}, "been": {},
"being": {}, "have": {}, "has": {}, "had": {}, "do": {},
"does": {}, "did": {}, "will": {}, "would": {}, "should": {},
"could": {}, "may": {}, "might": {}, "must": {}, "can": {},
}
// CleanupFunc is a callback for when observations are cleaned up.
// Receives the IDs of deleted observations for downstream cleanup (e.g., vector DB).
type CleanupFunc func(ctx context.Context, deletedIDs []int64)
// ObservationStore provides observation-related database operations using GORM.
type ObservationStore struct {
db *gorm.DB
rawDB *sql.DB
cleanupFunc CleanupFunc
conflictStore interface{} // Placeholder for ConflictStore (Phase 4)
relationStore interface{} // Placeholder for RelationStore (Phase 4)
conflictStore any
relationStore any
db *gorm.DB
rawDB *sql.DB
cleanupFunc CleanupFunc
cleanupQueue chan string
stopCleanup chan struct{}
cleanupWg sync.WaitGroup
cleanupOnce sync.Once
cleanupStarted atomic.Bool
}
// NewObservationStore creates a new observation store.
// The conflictStore and relationStore parameters are optional (can be nil) and will be used in Phase 4.
func NewObservationStore(store *Store, cleanupFunc CleanupFunc, conflictStore, relationStore interface{}) *ObservationStore {
return &ObservationStore{
func NewObservationStore(store *Store, cleanupFunc CleanupFunc, conflictStore, relationStore any) *ObservationStore {
s := &ObservationStore{
db: store.DB,
rawDB: store.GetRawDB(),
cleanupFunc: cleanupFunc,
conflictStore: conflictStore,
relationStore: relationStore,
cleanupQueue: make(chan string, cleanupQueueSize),
stopCleanup: make(chan struct{}),
}
// Start the cleanup worker
s.startCleanupWorker()
return s
}
// startCleanupWorker starts the background cleanup worker.
func (s *ObservationStore) startCleanupWorker() {
s.cleanupOnce.Do(func() {
s.cleanupStarted.Store(true)
s.cleanupWg.Add(1)
go s.cleanupWorker()
})
}
// cleanupWorker processes cleanup requests from the queue.
func (s *ObservationStore) cleanupWorker() {
defer s.cleanupWg.Done()
for {
select {
case <-s.stopCleanup:
// Drain remaining items in queue before exiting
for {
select {
case project := <-s.cleanupQueue:
s.processCleanup(project)
default:
return
}
}
case project := <-s.cleanupQueue:
s.processCleanup(project)
}
}
}
// processCleanup performs the actual cleanup for a project.
func (s *ObservationStore) processCleanup(project string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deletedIDs, err := s.CleanupOldObservations(ctx, project)
if err != nil {
log.Warn().Err(err).Str("project", project).Msg("Failed to cleanup old observations")
return
}
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
s.cleanupFunc(ctx, deletedIDs)
log.Debug().Str("project", project).Int("count", len(deletedIDs)).Msg("Cleaned up old observations")
}
}
// Close stops the cleanup worker and waits for it to finish.
// Safe to call even if the worker was never started.
func (s *ObservationStore) Close() {
// Only stop if worker was actually started to avoid deadlock
if s.cleanupStarted.Load() {
close(s.stopCleanup)
s.cleanupWg.Wait()
}
}
@@ -86,16 +173,15 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p
return 0, 0, err
}
// Cleanup old observations beyond the limit for this project (async to not block handler)
// Queue cleanup of old observations beyond the limit for this project (async to not block handler)
if project != "" {
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)
select {
case s.cleanupQueue <- project:
// Successfully queued for cleanup
default:
// Queue is full, log a warning instead of silently dropping
log.Warn().Str("project", project).Msg("Cleanup queue full, skipping cleanup for this observation")
}
}
// Note: Conflict and relation detection intentionally omitted for now
@@ -104,6 +190,89 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p
return dbObs.ID, nowEpoch, nil
}
// ObservationUpdate contains fields that can be updated on an observation.
// Only non-nil fields will be updated.
type ObservationUpdate struct {
Title *string // New title
Subtitle *string // New subtitle
Narrative *string // New narrative
Facts *[]string // New facts (replaces existing)
Concepts *[]string // New concepts (replaces existing)
FilesRead *[]string // New files read (replaces existing)
FilesModified *[]string // New files modified (replaces existing)
Scope *string // New scope (project or global)
}
// UpdateObservation updates an existing observation with the provided fields.
// Only non-nil fields in the update struct will be modified.
// Returns the updated observation or an error.
func (s *ObservationStore) UpdateObservation(ctx context.Context, id int64, update *ObservationUpdate) (*models.Observation, error) {
if update == nil {
return nil, fmt.Errorf("update cannot be nil")
}
// First, verify the observation exists
var dbObs Observation
if err := s.db.WithContext(ctx).First(&dbObs, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("observation not found: %d", id)
}
return nil, err
}
// Build update map with only provided fields
updates := make(map[string]any)
if update.Title != nil {
updates["title"] = sql.NullString{String: *update.Title, Valid: true}
}
if update.Subtitle != nil {
updates["subtitle"] = sql.NullString{String: *update.Subtitle, Valid: true}
}
if update.Narrative != nil {
updates["narrative"] = sql.NullString{String: *update.Narrative, Valid: true}
}
if update.Facts != nil {
factsJSON, _ := json.Marshal(*update.Facts)
updates["facts"] = string(factsJSON)
}
if update.Concepts != nil {
conceptsJSON, _ := json.Marshal(*update.Concepts)
updates["concepts"] = string(conceptsJSON)
}
if update.FilesRead != nil {
filesReadJSON, _ := json.Marshal(*update.FilesRead)
updates["files_read"] = string(filesReadJSON)
}
if update.FilesModified != nil {
filesModifiedJSON, _ := json.Marshal(*update.FilesModified)
updates["files_modified"] = string(filesModifiedJSON)
}
if update.Scope != nil {
updates["scope"] = sql.NullString{String: *update.Scope, Valid: true}
}
// Add updated_at timestamp
updates["updated_at_epoch"] = sql.NullInt64{Int64: time.Now().Unix(), Valid: true}
if len(updates) == 0 {
// Nothing to update, just return existing observation
return toModelObservation(&dbObs), nil
}
// Perform the update
if err := s.db.WithContext(ctx).Model(&Observation{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, fmt.Errorf("failed to update observation: %w", err)
}
// Fetch the updated observation
if err := s.db.WithContext(ctx).First(&dbObs, id).Error; err != nil {
return nil, err
}
return toModelObservation(&dbObs), nil
}
// GetObservationByID retrieves an observation by its ID.
func (s *ObservationStore) GetObservationByID(ctx context.Context, id int64) (*models.Observation, error) {
var dbObs Observation
@@ -134,6 +303,8 @@ func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64
query = query.Order("created_at_epoch DESC")
case "importance":
query = query.Order("importance_score DESC, created_at_epoch DESC")
case "score_desc":
query = query.Order("importance_score DESC, created_at_epoch DESC")
default:
// Default: importance first, then recency
query = query.Order("COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC")
@@ -152,6 +323,60 @@ func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64
return toModelObservations(dbObservations), nil
}
// GetObservationsByIDsPreserveOrder retrieves observations by IDs, preserving the input order.
// This is useful when the caller has already sorted/ranked the IDs (e.g., by vector similarity).
func (s *ObservationStore) GetObservationsByIDsPreserveOrder(ctx context.Context, ids []int64) ([]*models.Observation, error) {
if len(ids) == 0 {
return nil, nil
}
// Fetch all observations in a single query
var dbObservations []Observation
err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&dbObservations).Error
if err != nil {
return nil, err
}
// Build ID -> observation map for O(1) lookups
obsMap := make(map[int64]*Observation, len(dbObservations))
for i := range dbObservations {
obsMap[int64(dbObservations[i].ID)] = &dbObservations[i]
}
// Reconstruct in original order
result := make([]*models.Observation, 0, len(ids))
for _, id := range ids {
if obs, ok := obsMap[id]; ok {
result = append(result, toModelObservation(obs))
}
}
return result, nil
}
// BatchGetObservationsWithScores retrieves observations with associated scores.
// Returns a map of ID -> observation for efficient lookup.
func (s *ObservationStore) BatchGetObservationsWithScores(ctx context.Context, ids []int64) (map[int64]*models.Observation, error) {
if len(ids) == 0 {
return make(map[int64]*models.Observation), nil
}
// Fetch all observations in a single query
var dbObservations []Observation
err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&dbObservations).Error
if err != nil {
return nil, err
}
// Build result map
result := make(map[int64]*models.Observation, len(dbObservations))
for i := range dbObservations {
result[int64(dbObservations[i].ID)] = toModelObservation(&dbObservations[i])
}
return result, nil
}
// GetRecentObservations retrieves recent observations for a project.
// This includes project-scoped observations for the specified project AND global observations.
// Results are ordered by importance_score DESC, then created_at_epoch DESC.
@@ -169,13 +394,13 @@ func (s *ObservationStore) GetRecentObservations(ctx context.Context, project st
return toModelObservations(dbObservations), nil
}
// GetActiveObservations retrieves recent non-superseded observations for a project.
// This excludes observations that have been marked as superseded by newer ones.
// GetActiveObservations retrieves recent non-superseded, non-archived observations for a project.
// This excludes observations that have been marked as superseded or archived.
// Results are ordered by importance_score DESC, then created_at_epoch DESC.
func (s *ObservationStore) GetActiveObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
var dbObservations []Observation
err := s.db.WithContext(ctx).
Scopes(projectScopeFilter(project), notSupersededFilter(), importanceOrdering()).
Scopes(projectScopeFilter(project), activeObservationFilter(), importanceOrdering()).
Limit(limit).
Find(&dbObservations).Error
@@ -245,7 +470,57 @@ func (s *ObservationStore) GetAllRecentObservations(ctx context.Context, limit i
return toModelObservations(dbObservations), nil
}
// GetAllRecentObservationsPaginated retrieves recent observations with pagination.
func (s *ObservationStore) GetAllRecentObservationsPaginated(ctx context.Context, limit, offset int) ([]*models.Observation, int64, error) {
var dbObservations []Observation
var total int64
// Get total count
if err := s.db.WithContext(ctx).Model(&Observation{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
err := s.db.WithContext(ctx).
Scopes(importanceOrdering()).
Limit(limit).
Offset(offset).
Find(&dbObservations).Error
if err != nil {
return nil, 0, err
}
return toModelObservations(dbObservations), total, nil
}
// GetObservationsByProjectStrictPaginated retrieves observations strictly from a project with pagination.
func (s *ObservationStore) GetObservationsByProjectStrictPaginated(ctx context.Context, project string, limit, offset int) ([]*models.Observation, int64, error) {
var dbObservations []Observation
var total int64
// Get total count for project
if err := s.db.WithContext(ctx).Model(&Observation{}).Where("project = ?", project).Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
err := s.db.WithContext(ctx).
Where("project = ?", project).
Scopes(importanceOrdering()).
Limit(limit).
Offset(offset).
Find(&dbObservations).Error
if err != nil {
return nil, 0, err
}
return toModelObservations(dbObservations), total, nil
}
// GetAllObservations retrieves all observations (for vector rebuild).
// Note: For large datasets, prefer GetAllObservationsIterator to avoid memory issues.
func (s *ObservationStore) GetAllObservations(ctx context.Context) ([]*models.Observation, error) {
var dbObservations []Observation
err := s.db.WithContext(ctx).
@@ -259,6 +534,51 @@ func (s *ObservationStore) GetAllObservations(ctx context.Context) ([]*models.Ob
return toModelObservations(dbObservations), nil
}
// GetAllObservationsIterator returns observations in batches to avoid loading all into memory.
// The callback is called for each batch. Return false from callback to stop iteration.
// batchSize controls how many observations are loaded at once (default 500 if <= 0).
func (s *ObservationStore) GetAllObservationsIterator(ctx context.Context, batchSize int, callback func([]*models.Observation) bool) error {
if batchSize <= 0 {
batchSize = 500
}
var lastID int64 = 0
for {
// Check context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
var dbObservations []Observation
err := s.db.WithContext(ctx).
Where("id > ?", lastID).
Order("id ASC").
Limit(batchSize).
Find(&dbObservations).Error
if err != nil {
return err
}
if len(dbObservations) == 0 {
break // No more observations
}
// Update cursor for next batch
lastID = dbObservations[len(dbObservations)-1].ID
// Convert and call callback
observations := toModelObservations(dbObservations)
if !callback(observations) {
break // Callback requested stop
}
}
return nil
}
// SearchObservationsFTS performs full-text search on observations using FTS5.
// Falls back to LIKE search if FTS5 fails.
func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, project string, limit int) ([]*models.Observation, error) {
@@ -314,14 +634,26 @@ func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, pro
}
// searchObservationsLike performs fallback LIKE search on observations using GORM.
// Limits to 2 keywords to prevent expensive OR queries that SQLite optimizes poorly.
// This is a fallback path when FTS returns no results, so we prioritize performance.
func (s *ObservationStore) searchObservationsLike(ctx context.Context, keywords []string, project string, limit int) ([]*models.Observation, error) {
if len(keywords) == 0 {
return nil, nil
}
// Limit keywords to prevent excessive OR conditions that hurt query planning.
// SQLite performs significantly better with fewer LIKE conditions.
// Using 2 instead of 5 reduces query complexity from O(15) to O(6) conditions
// (each keyword creates 3 LIKE conditions for title, subtitle, narrative).
const maxKeywords = 2
if len(keywords) > maxKeywords {
keywords = keywords[:maxKeywords]
}
// Build LIKE conditions for each keyword
var conditions []string
var args []interface{}
// Pre-allocate for efficiency: maxKeywords conditions × 3 args each + 1 project arg
conditions := make([]string, 0, len(keywords))
args := make([]any, 0, len(keywords)*3+1)
for _, kw := range keywords {
pattern := "%" + kw + "%"
@@ -358,6 +690,217 @@ func (s *ObservationStore) DeleteObservations(ctx context.Context, ids []int64)
return result.RowsAffected, result.Error
}
// DeleteObservation deletes a single observation by ID.
func (s *ObservationStore) DeleteObservation(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).Delete(&Observation{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("observation %d not found", id)
}
return nil
}
// MarkAsSuperseded marks an observation as superseded (stale).
func (s *ObservationStore) MarkAsSuperseded(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).
Model(&Observation{}).
Where("id = ?", id).
Update("is_superseded", 1)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("observation %d not found", id)
}
return nil
}
// MarkAsSupersededBatch marks multiple observations as superseded in a single query.
// Returns the number of observations updated and any error.
func (s *ObservationStore) MarkAsSupersededBatch(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
result := s.db.WithContext(ctx).
Model(&Observation{}).
Where("id IN ?", ids).
Update("is_superseded", 1)
return result.RowsAffected, result.Error
}
// ArchiveObservation archives a single observation with an optional reason.
func (s *ObservationStore) ArchiveObservation(ctx context.Context, id int64, reason string) error {
updates := map[string]any{
"is_archived": 1,
"archived_at_epoch": time.Now().UnixMilli(),
}
if reason != "" {
updates["archived_reason"] = reason
}
result := s.db.WithContext(ctx).
Model(&Observation{}).
Where("id = ?", id).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("observation %d not found", id)
}
return nil
}
// UnarchiveObservation restores an archived observation.
func (s *ObservationStore) UnarchiveObservation(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).
Model(&Observation{}).
Where("id = ?", id).
Updates(map[string]any{
"is_archived": 0,
"archived_at_epoch": nil,
"archived_reason": nil,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("observation %d not found", id)
}
return nil
}
// ArchiveOldObservations archives observations older than the specified age.
// Returns the count of archived observations and their IDs.
func (s *ObservationStore) ArchiveOldObservations(ctx context.Context, project string, maxAgeDays int, reason string) ([]int64, error) {
if maxAgeDays <= 0 {
maxAgeDays = 90 // Default: archive observations older than 90 days
}
cutoffEpoch := time.Now().AddDate(0, 0, -maxAgeDays).UnixMilli()
if reason == "" {
reason = fmt.Sprintf("auto-archived: older than %d days", maxAgeDays)
}
var idsToArchive []int64
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Find observations to archive (not already archived, older than cutoff)
query := tx.Model(&Observation{}).
Where("created_at_epoch < ?", cutoffEpoch).
Where("COALESCE(is_archived, 0) = 0")
if project != "" {
query = query.Where("project = ?", project)
}
if err := query.Pluck("id", &idsToArchive).Error; err != nil {
return err
}
if len(idsToArchive) == 0 {
return nil
}
// Archive the observations
now := time.Now().UnixMilli()
return tx.Model(&Observation{}).
Where("id IN ?", idsToArchive).
Updates(map[string]any{
"is_archived": 1,
"archived_at_epoch": now,
"archived_reason": reason,
}).Error
})
if err != nil {
return nil, err
}
return idsToArchive, nil
}
// GetArchivedObservations retrieves archived observations for a project.
func (s *ObservationStore) GetArchivedObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
var dbObservations []Observation
query := s.db.WithContext(ctx).
Where("COALESCE(is_archived, 0) = 1")
if project != "" {
query = query.Where("project = ?", project)
}
err := query.
Order("archived_at_epoch DESC").
Limit(limit).
Find(&dbObservations).Error
if err != nil {
return nil, err
}
return toModelObservations(dbObservations), nil
}
// GetArchivalStats returns statistics about archived observations.
// Optimized to use a single query instead of 4 separate queries.
func (s *ObservationStore) GetArchivalStats(ctx context.Context, project string) (*ArchivalStats, error) {
// Use a single query with conditional aggregation to get all stats at once.
// This is much faster than 4 separate queries (saves 3 round trips).
type statsResult struct {
OldestEpoch *int64
NewestEpoch *int64
TotalCount int64
ArchivedCount int64
}
var result statsResult
query := s.db.WithContext(ctx).Model(&Observation{}).
Select(`
COUNT(*) as total_count,
SUM(CASE WHEN COALESCE(is_archived, 0) = 1 THEN 1 ELSE 0 END) as archived_count,
MIN(CASE WHEN COALESCE(is_archived, 0) = 1 THEN archived_at_epoch END) as oldest_epoch,
MAX(CASE WHEN COALESCE(is_archived, 0) = 1 THEN archived_at_epoch END) as newest_epoch
`)
if project != "" {
query = query.Where("project = ?", project)
}
if err := query.Scan(&result).Error; err != nil {
return nil, err
}
stats := &ArchivalStats{
TotalCount: result.TotalCount,
ArchivedCount: result.ArchivedCount,
ActiveCount: result.TotalCount - result.ArchivedCount,
}
if result.OldestEpoch != nil {
stats.OldestArchivedEpoch = *result.OldestEpoch
}
if result.NewestEpoch != nil {
stats.NewestArchivedEpoch = *result.NewestEpoch
}
return stats, nil
}
// ArchivalStats contains statistics about archived observations.
type ArchivalStats struct {
TotalCount int64 `json:"total_count"`
ActiveCount int64 `json:"active_count"`
ArchivedCount int64 `json:"archived_count"`
OldestArchivedEpoch int64 `json:"oldest_archived_epoch,omitempty"`
NewestArchivedEpoch int64 `json:"newest_archived_epoch,omitempty"`
}
// CleanupOldObservations removes observations beyond the limit for a project.
// Returns the IDs of deleted observations.
func (s *ObservationStore) CleanupOldObservations(ctx context.Context, project string) ([]int64, error) {
@@ -418,10 +961,12 @@ func projectScopeFilter(project string) func(*gorm.DB) *gorm.DB {
}
}
// notSupersededFilter filters out superseded observations.
func notSupersededFilter() func(*gorm.DB) *gorm.DB {
// activeObservationFilter filters for active (non-archived, non-superseded) observations.
// This is more efficient than chaining notSupersededFilter + notArchivedFilter
// as it produces a single WHERE clause for the query optimizer.
func activeObservationFilter() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("COALESCE(is_superseded, 0) = 0")
return db.Where("COALESCE(is_archived, 0) = 0 AND COALESCE(is_superseded, 0) = 0")
}
}
@@ -437,23 +982,17 @@ func importanceOrdering() func(*gorm.DB) *gorm.DB {
// ====================
// extractKeywords extracts keywords from a search query.
// Uses package-level commonWords map for O(1) stop word filtering.
func extractKeywords(query string) []string {
words := strings.Fields(strings.ToLower(query))
var keywords []string
commonWords := map[string]bool{
"the": true, "and": true, "or": true, "but": true, "in": true,
"on": true, "at": true, "to": true, "for": true, "of": true,
"with": true, "by": true, "from": true, "as": true, "is": true,
"was": true, "are": true, "were": true, "be": true, "been": true,
"being": true, "have": true, "has": true, "had": true, "do": true,
"does": true, "did": true, "will": true, "would": true, "should": true,
"could": true, "may": true, "might": true, "must": true, "can": true,
}
keywords := make([]string, 0, len(words)) // Pre-allocate for typical case
for _, word := range words {
// Skip short words and common words
if len(word) <= 3 || commonWords[word] {
// Skip short words and common stop words
if len(word) <= 3 {
continue
}
if _, isCommon := commonWords[word]; isCommon {
continue
}
keywords = append(keywords, word)
@@ -464,7 +1003,8 @@ func extractKeywords(query string) []string {
// scanObservationRows scans multiple observations from raw SQL rows.
func scanObservationRows(rows *sql.Rows) ([]*models.Observation, error) {
var observations []*models.Observation
// Pre-allocate with reasonable initial capacity to avoid repeated slice growth
observations := make([]*models.Observation, 0, 64)
for rows.Next() {
obs, err := scanObservation(rows)
if err != nil {
@@ -476,7 +1016,7 @@ func scanObservationRows(rows *sql.Rows) ([]*models.Observation, error) {
}
// scanObservation scans a single observation from a row scanner.
func scanObservation(scanner interface{ Scan(...interface{}) error }) (*models.Observation, error) {
func scanObservation(scanner interface{ Scan(...any) error }) (*models.Observation, error) {
var obs models.Observation
var factsJSON, conceptsJSON, filesReadJSON, filesModifiedJSON, fileMtimesJSON []byte
var isSuperseded int
+15 -2
View File
@@ -4,6 +4,8 @@ package gorm
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
"gorm.io/gorm"
@@ -18,6 +20,7 @@ type PatternCleanupFunc func(ctx context.Context, deletedIDs []int64)
type PatternStore struct {
db *gorm.DB
cleanupFunc PatternCleanupFunc
cleanupMu sync.RWMutex
}
// NewPatternStore creates a new pattern store.
@@ -29,6 +32,8 @@ func NewPatternStore(store *Store) *PatternStore {
// SetCleanupFunc sets the callback for when patterns are deleted.
func (s *PatternStore) SetCleanupFunc(fn PatternCleanupFunc) {
s.cleanupMu.Lock()
defer s.cleanupMu.Unlock()
s.cleanupFunc = fn
}
@@ -238,6 +243,9 @@ func (s *PatternStore) MarkPatternDeprecated(ctx context.Context, id int64) erro
// MergePatterns merges a source pattern into a target pattern.
func (s *PatternStore) MergePatterns(ctx context.Context, sourceID, targetID int64) error {
if sourceID == targetID {
return fmt.Errorf("cannot merge pattern into itself")
}
// Get both patterns
source, err := s.GetPatternByID(ctx, sourceID)
if err != nil {
@@ -294,8 +302,13 @@ func (s *PatternStore) MergePatterns(ctx context.Context, sourceID, targetID int
func (s *PatternStore) DeletePattern(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).Delete(&Pattern{}, id)
if result.Error == nil && s.cleanupFunc != nil {
s.cleanupFunc(ctx, []int64{id})
if result.Error == nil {
s.cleanupMu.RLock()
fn := s.cleanupFunc
s.cleanupMu.RUnlock()
if fn != nil {
fn(ctx, []int64{id})
}
}
return result.Error
+36 -3
View File
@@ -4,8 +4,10 @@ package gorm
import (
"context"
"database/sql"
"sync"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -23,6 +25,7 @@ const MaxPromptsGlobal = 500
type PromptStore struct {
db *gorm.DB
cleanupFunc PromptCleanupFunc
cleanupMu sync.RWMutex
}
// NewPromptStore creates a new prompt store.
@@ -35,6 +38,8 @@ func NewPromptStore(store *Store, cleanupFunc PromptCleanupFunc) *PromptStore {
// SetCleanupFunc sets the callback for when prompts are deleted during cleanup.
func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
s.cleanupMu.Lock()
defer s.cleanupMu.Unlock()
s.cleanupFunc = fn
}
@@ -81,9 +86,15 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi
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)
if deletedIDs, err := s.CleanupOldPrompts(cleanupCtx); err != nil {
log.Warn().Err(err).Msg("Background prompt cleanup failed")
} else if len(deletedIDs) > 0 {
s.cleanupMu.RLock()
fn := s.cleanupFunc
s.cleanupMu.RUnlock()
if fn != nil {
fn(cleanupCtx, deletedIDs)
}
}
}()
@@ -253,6 +264,28 @@ func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionI
return prompt.ID, prompt.PromptNumber, true
}
// FindRecentPromptByTextGlobal finds a recent prompt by exact text match within a time window
// across ALL sessions (not scoped to a single claude session ID).
// This catches duplicates when the same prompt arrives from different session IDs,
// e.g. when the SDK processor's callClaudeCLI subprocess fires with a different session ID.
// Returns (promptID, promptNumber, found).
func (s *PromptStore) FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool) {
cutoffEpoch := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli()
var prompt UserPrompt
err := s.db.WithContext(ctx).
Where("prompt_text = ? AND created_at_epoch >= ?",
promptText, cutoffEpoch).
Order("created_at_epoch DESC").
First(&prompt).Error
if err != nil {
return 0, 0, false
}
return prompt.ID, prompt.PromptNumber, true
}
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
var results []struct {
+33
View File
@@ -333,6 +333,39 @@ func TestPromptStore_FindRecentPromptByText(t *testing.T) {
assert.False(t, notFound, "Should not find prompt outside time window")
}
func TestPromptStore_FindRecentPromptByTextGlobal(t *testing.T) {
promptStore, _, cleanup := testPromptStore(t)
defer cleanup()
ctx := context.Background()
// Save a prompt under session "claude-1"
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the architecture?", 3)
require.NoError(t, err)
// Global search should find it without specifying session ID
foundID, foundNumber, found := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60)
assert.True(t, found, "Should find the prompt globally")
assert.Equal(t, id, foundID)
assert.Equal(t, 1, foundNumber)
// Global search should find it even when a different session ID would have been used
// (This is the core cross-session dedup scenario)
foundID2, foundNumber2, found2 := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60)
assert.True(t, found2, "Should find the prompt from any session")
assert.Equal(t, id, foundID2)
assert.Equal(t, 1, foundNumber2)
// Different text should not match
_, _, notFound := promptStore.FindRecentPromptByTextGlobal(ctx, "Different text", 60)
assert.False(t, notFound, "Should not find a different prompt")
// Should not find prompt outside time window
time.Sleep(100 * time.Millisecond)
_, _, notFound = promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 0)
assert.False(t, notFound, "Should not find prompt outside time window")
}
func TestPromptStore_GetRecentUserPromptsByProject(t *testing.T) {
promptStore, store, cleanup := testPromptStore(t)
defer cleanup()
+44 -1
View File
@@ -3,6 +3,8 @@ package gorm
import (
"context"
"fmt"
"strings"
"time"
"gorm.io/gorm"
@@ -59,12 +61,23 @@ func (s *ObservationStore) UpdateImportanceScore(ctx context.Context, id int64,
}
// UpdateImportanceScores bulk updates importance scores for multiple observations.
// This is more efficient than individual updates for batch recalculation.
// Uses a single SQL statement with CASE/WHEN for efficient batch updates.
func (s *ObservationStore) UpdateImportanceScores(ctx context.Context, scores map[int64]float64) error {
if len(scores) == 0 {
return nil
}
// For small batches, use simple individual updates
if len(scores) <= 5 {
return s.updateScoresIndividually(ctx, scores)
}
// For larger batches, use CASE/WHEN SQL for single-query update
return s.updateScoresBatch(ctx, scores)
}
// updateScoresIndividually updates scores one at a time (efficient for small batches).
func (s *ObservationStore) updateScoresIndividually(ctx context.Context, scores map[int64]float64) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now().UnixMilli()
@@ -85,6 +98,36 @@ func (s *ObservationStore) UpdateImportanceScores(ctx context.Context, scores ma
})
}
// updateScoresBatch updates multiple scores in a single SQL statement using CASE/WHEN.
// This is much more efficient for large batches (O(1) queries instead of O(n)).
func (s *ObservationStore) updateScoresBatch(ctx context.Context, scores map[int64]float64) error {
now := time.Now().UnixMilli()
// Build CASE/WHEN clause for importance_score
// UPDATE observations SET
// importance_score = CASE id WHEN 1 THEN 0.5 WHEN 2 THEN 0.8 ... END,
// score_updated_at_epoch = ?
// WHERE id IN (1, 2, ...)
ids := make([]int64, 0, len(scores))
caseBuilder := strings.Builder{}
caseBuilder.WriteString("CASE id ")
for id, score := range scores {
ids = append(ids, id)
caseBuilder.WriteString(fmt.Sprintf("WHEN %d THEN %f ", id, score))
}
caseBuilder.WriteString("END")
// Use raw SQL for the batch update
sql := fmt.Sprintf(
"UPDATE observations SET importance_score = %s, score_updated_at_epoch = ? WHERE id IN ?",
caseBuilder.String(),
)
return s.db.WithContext(ctx).Exec(sql, now, ids).Error
}
// GetObservationsNeedingScoreUpdate returns observations that need their importance score recalculated.
// Returns observations where score_updated_at_epoch is NULL or older than the threshold.
func (s *ObservationStore) GetObservationsNeedingScoreUpdate(ctx context.Context, threshold time.Duration, limit int) ([]*models.Observation, error) {
+33 -15
View File
@@ -119,26 +119,44 @@ func (s *SessionStore) FindAnySDKSession(ctx context.Context, claudeSessionID st
}
// IncrementPromptCounter increments the prompt counter and returns the new value.
// Uses a single SQL query with RETURNING clause for optimal performance.
func (s *SessionStore) IncrementPromptCounter(ctx context.Context, id int64) (int, error) {
// Atomic increment using GORM expression
err := s.db.WithContext(ctx).
Model(&SDKSession{}).
Where("id = ?", id).
Update("prompt_counter", gorm.Expr("COALESCE(prompt_counter, 0) + 1")).Error
// Use raw SQL with RETURNING to get updated value in single query
// SQLite supports RETURNING since version 3.35.0 (2021-03-12)
var newCounter int
err := s.db.WithContext(ctx).Raw(`
UPDATE sdk_sessions
SET prompt_counter = COALESCE(prompt_counter, 0) + 1
WHERE id = ?
RETURNING prompt_counter
`, id).Scan(&newCounter).Error
if err != nil {
// Fallback for older SQLite versions without RETURNING support
if err.Error() == "near \"RETURNING\": syntax error" || newCounter == 0 {
// Atomic increment
updateErr := s.db.WithContext(ctx).
Model(&SDKSession{}).
Where("id = ?", id).
Update("prompt_counter", gorm.Expr("COALESCE(prompt_counter, 0) + 1")).Error
if updateErr != nil {
return 0, updateErr
}
// Fetch updated value
var sess SDKSession
fetchErr := s.db.WithContext(ctx).
Select("prompt_counter").
First(&sess, id).Error
if fetchErr != nil {
return 0, fetchErr
}
return sess.PromptCounter, nil
}
return 0, err
}
// Fetch updated value
var sess SDKSession
err = s.db.WithContext(ctx).
Select("prompt_counter").
First(&sess, id).Error
if err != nil {
return 0, err
}
return sess.PromptCounter, nil
return newCounter, nil
}
// GetPromptCounter returns the current prompt counter for a session.
+426 -9
View File
@@ -2,11 +2,16 @@
package gorm
import (
"context"
"database/sql"
"fmt"
"slices"
"sync"
"time"
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
_ "github.com/mattn/go-sqlite3" // Import SQLite driver with FTS5 support
"github.com/rs/zerolog/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -14,8 +19,13 @@ import (
// Store represents the GORM database connection with sqlite-vec support.
type Store struct {
DB *gorm.DB
sqlDB *sql.DB // For FTS5 and sqlite-vec operations that require raw SQL
healthCacheTime time.Time
DB *gorm.DB
sqlDB *sql.DB
metrics *PoolMetrics
cachedHealth *HealthInfo
healthCacheTTL time.Duration
healthCacheMu sync.RWMutex
}
// Config holds database configuration.
@@ -71,8 +81,10 @@ func NewStore(cfg Config) (*Store, error) {
}
store := &Store{
DB: db,
sqlDB: sqlDB,
DB: db,
sqlDB: sqlDB,
metrics: NewPoolMetrics(100), // Track last 100 latency samples
healthCacheTTL: 5 * time.Second, // Cache health checks for 5 seconds
}
// 7. Run migrations FIRST (before PRAGMA commands)
@@ -80,13 +92,20 @@ func NewStore(cfg Config) (*Store, error) {
return nil, fmt.Errorf("run migrations: %w", err)
}
// 8. CRITICAL: Set WAL mode and synchronous mode via raw SQL
// 8. CRITICAL: Set WAL mode and other performance pragmas
// Use raw sqlDB to avoid GORM transaction issues
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("set WAL mode: %w", err)
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA cache_size=-64000", // 64MB cache (negative = KB)
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
}
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
return nil, fmt.Errorf("set synchronous mode: %w", err)
for _, pragma := range pragmas {
if _, err := sqlDB.Exec(pragma); err != nil {
log.Warn().Str("pragma", pragma).Err(err).Msg("Failed to set pragma (non-fatal)")
}
}
// Set busy timeout to 5 seconds to handle concurrent writes
// This allows SQLite to retry when database is locked instead of failing immediately
@@ -94,9 +113,40 @@ func NewStore(cfg Config) (*Store, error) {
return nil, fmt.Errorf("set busy timeout: %w", err)
}
// 9. Warm the connection pool
store.WarmPool(maxConns)
return store, nil
}
// WarmPool pre-creates connections to avoid cold start latency.
func (s *Store) WarmPool(numConns int) {
if numConns <= 0 {
numConns = 4
}
var wg sync.WaitGroup
for i := 0; i < numConns; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
conn, err := s.sqlDB.Conn(ctx)
if err != nil {
return
}
// Execute a simple query to ensure the connection is fully initialized
_ = conn.PingContext(ctx)
// Return connection to pool (don't close it)
_ = conn.Close()
}()
}
wg.Wait()
log.Debug().Int("connections", numConns).Msg("Connection pool warmed")
}
// Close closes the database connection.
func (s *Store) Close() error {
return s.sqlDB.Close()
@@ -120,3 +170,370 @@ func (s *Store) GetRawDB() *sql.DB {
func (s *Store) GetDB() *gorm.DB {
return s.DB
}
// Stats returns database connection pool statistics.
func (s *Store) Stats() sql.DBStats {
return s.sqlDB.Stats()
}
// Optimize runs VACUUM and ANALYZE to optimize the database.
// Should be called periodically (e.g., daily) during low activity.
func (s *Store) Optimize(ctx context.Context) error {
log.Info().Msg("Starting database optimization")
start := time.Now()
// ANALYZE updates statistics for query optimizer
if _, err := s.sqlDB.ExecContext(ctx, "ANALYZE"); err != nil {
return fmt.Errorf("analyze: %w", err)
}
// PRAGMA optimize runs optimization based on query statistics
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA optimize"); err != nil {
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
}
log.Info().Dur("duration", time.Since(start)).Msg("Database optimization complete")
return nil
}
// HealthCheck performs a comprehensive health check with latency measurement.
// Returns detailed health information including connection pool stats and query latency.
// Results are cached for healthCacheTTL (default 5 seconds) to reduce database load
// from frequent monitoring calls.
func (s *Store) HealthCheck(ctx context.Context) *HealthInfo {
// Fast path: check cache with read lock
s.healthCacheMu.RLock()
if s.cachedHealth != nil && time.Since(s.healthCacheTime) < s.healthCacheTTL {
cached := s.cachedHealth
s.healthCacheMu.RUnlock()
return cached
}
s.healthCacheMu.RUnlock()
// Slow path: perform actual health check
info := s.performHealthCheck(ctx)
// Cache the result
s.healthCacheMu.Lock()
s.cachedHealth = info
s.healthCacheTime = time.Now()
s.healthCacheMu.Unlock()
return info
}
// HealthCheckForce performs a health check bypassing the cache.
// Use this when you need real-time health data (e.g., debugging, alerting).
func (s *Store) HealthCheckForce(ctx context.Context) *HealthInfo {
info := s.performHealthCheck(ctx)
// Update the cache with fresh data
s.healthCacheMu.Lock()
s.cachedHealth = info
s.healthCacheTime = time.Now()
s.healthCacheMu.Unlock()
return info
}
// performHealthCheck does the actual health check work.
func (s *Store) performHealthCheck(ctx context.Context) *HealthInfo {
info := &HealthInfo{
Status: "healthy",
Timestamp: time.Now(),
}
// Check pool stats
stats := s.sqlDB.Stats()
info.PoolStats = PoolStats{
OpenConnections: stats.OpenConnections,
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration,
MaxIdleClosed: stats.MaxIdleClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
}
// Record pool stats for metrics tracking
if s.metrics != nil {
s.metrics.RecordPoolStats(stats)
}
// Measure query latency with a simple SELECT
start := time.Now()
var dummy int
err := s.sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&dummy)
info.QueryLatency = time.Since(start)
// Record latency for historical tracking
if s.metrics != nil {
s.metrics.RecordLatency(info.QueryLatency)
info.HistoricalMetrics = s.metrics.GetMetricsSummary()
}
if err != nil {
info.Status = "unhealthy"
info.Error = err.Error()
return info
}
// Check for connection saturation (degraded if pool is heavily used)
if stats.InUse > 0 && float64(stats.InUse)/float64(stats.OpenConnections) > 0.8 {
info.Status = "degraded"
info.Warning = "Connection pool heavily utilized"
}
// Check for wait contention
if stats.WaitCount > 100 && stats.WaitDuration > 100*time.Millisecond {
info.Status = "degraded"
info.Warning = "Connection pool contention detected"
}
// Check query latency (warn if > 10ms for simple query)
if info.QueryLatency > 10*time.Millisecond {
if info.Status == "healthy" {
info.Status = "degraded"
}
info.Warning = fmt.Sprintf("Slow query latency: %v", info.QueryLatency)
}
// Check historical latency trend (degraded if P95 is high)
if s.metrics != nil && info.HistoricalMetrics.P95Latency > 50*time.Millisecond {
if info.Status == "healthy" {
info.Status = "degraded"
}
info.Warning = fmt.Sprintf("High P95 latency: %v", info.HistoricalMetrics.P95Latency)
}
return info
}
// HealthInfo contains database health check results.
type HealthInfo struct {
Timestamp time.Time `json:"timestamp"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Warning string `json:"warning,omitempty"`
HistoricalMetrics MetricsSummary `json:"historical_metrics,omitempty"`
PoolStats PoolStats `json:"pool_stats"`
QueryLatency time.Duration `json:"query_latency_ns"`
}
// PoolStats contains connection pool statistics.
type PoolStats struct {
OpenConnections int `json:"open_connections"`
InUse int `json:"in_use"`
Idle int `json:"idle"`
WaitCount int64 `json:"wait_count"`
WaitDuration time.Duration `json:"wait_duration_ns"`
MaxIdleClosed int64 `json:"max_idle_closed"`
MaxLifetimeClosed int64 `json:"max_lifetime_closed"`
}
// QueryTimeout constants for different query types.
const (
// DefaultQueryTimeout is the default timeout for regular queries.
DefaultQueryTimeout = 5 * time.Second
// FastQueryTimeout is for queries that should be very fast (health checks, etc).
FastQueryTimeout = 1 * time.Second
// SlowQueryTimeout is for queries that may take longer (bulk operations, rebuilds).
SlowQueryTimeout = 30 * time.Second
)
// PoolMetrics tracks historical connection pool metrics with a sliding window.
type PoolMetrics struct {
lastSampleTime time.Time
latencySamples []time.Duration
latencyIdx int
latencyCount int
totalQueries int64
totalWaitTime time.Duration
peakInUse int
peakWaitCount int64
windowSize int
mu sync.RWMutex
}
// NewPoolMetrics creates a new pool metrics collector with the given window size.
func NewPoolMetrics(windowSize int) *PoolMetrics {
if windowSize <= 0 {
windowSize = 100 // Default: track last 100 samples
}
return &PoolMetrics{
latencySamples: make([]time.Duration, windowSize),
windowSize: windowSize,
lastSampleTime: time.Now(),
}
}
// RecordLatency records a query latency sample.
func (m *PoolMetrics) RecordLatency(latency time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.latencySamples[m.latencyIdx] = latency
m.latencyIdx = (m.latencyIdx + 1) % m.windowSize
if m.latencyCount < m.windowSize {
m.latencyCount++
}
m.totalQueries++
m.lastSampleTime = time.Now()
}
// RecordPoolStats records pool statistics for peak tracking.
func (m *PoolMetrics) RecordPoolStats(stats sql.DBStats) {
m.mu.Lock()
defer m.mu.Unlock()
if stats.InUse > m.peakInUse {
m.peakInUse = stats.InUse
}
if stats.WaitCount > m.peakWaitCount {
m.peakWaitCount = stats.WaitCount
}
m.totalWaitTime += stats.WaitDuration
}
// GetMetricsSummary returns a summary of collected metrics.
func (m *PoolMetrics) GetMetricsSummary() MetricsSummary {
m.mu.RLock()
defer m.mu.RUnlock()
summary := MetricsSummary{
TotalQueries: m.totalQueries,
SampleCount: m.latencyCount,
PeakInUse: m.peakInUse,
PeakWaitCount: m.peakWaitCount,
TotalWaitTime: m.totalWaitTime,
LastSampleTime: m.lastSampleTime,
}
if m.latencyCount == 0 {
return summary
}
// Calculate latency statistics
var total time.Duration
var min, max time.Duration = m.latencySamples[0], m.latencySamples[0]
for i := 0; i < m.latencyCount; i++ {
sample := m.latencySamples[i]
total += sample
if sample < min {
min = sample
}
if sample > max {
max = sample
}
}
summary.AvgLatency = total / time.Duration(m.latencyCount)
summary.MinLatency = min
summary.MaxLatency = max
// Calculate P95 latency (approximate using sorted samples)
if m.latencyCount >= 20 {
// Copy samples for sorting
samples := make([]time.Duration, m.latencyCount)
copy(samples, m.latencySamples[:m.latencyCount])
// Use slices.Sort for O(n log n) instead of O(n²) insertion sort
slices.Sort(samples)
p95Idx := int(float64(len(samples)) * 0.95)
summary.P95Latency = samples[p95Idx]
}
return summary
}
// MetricsSummary contains aggregated pool metrics.
type MetricsSummary struct {
LastSampleTime time.Time `json:"last_sample_time"`
TotalQueries int64 `json:"total_queries"`
SampleCount int `json:"sample_count"`
AvgLatency time.Duration `json:"avg_latency_ns"`
MinLatency time.Duration `json:"min_latency_ns"`
MaxLatency time.Duration `json:"max_latency_ns"`
P95Latency time.Duration `json:"p95_latency_ns,omitempty"`
PeakInUse int `json:"peak_in_use"`
PeakWaitCount int64 `json:"peak_wait_count"`
TotalWaitTime time.Duration `json:"total_wait_time_ns"`
}
// GetMetrics returns the current metrics without performing a health check.
func (s *Store) GetMetrics() MetricsSummary {
if s.metrics == nil {
return MetricsSummary{}
}
return s.metrics.GetMetricsSummary()
}
// ResetMetrics resets the metrics collector (useful for testing or after major changes).
func (s *Store) ResetMetrics() {
if s.metrics != nil {
s.metrics = NewPoolMetrics(s.metrics.windowSize)
}
}
// WithTimeout wraps a context with the given timeout and logs slow queries.
// Returns the wrapped context and a cancel function that should be called when done.
func (s *Store) WithTimeout(ctx context.Context, timeout time.Duration, operation string) (context.Context, context.CancelFunc) {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
start := time.Now()
// Return wrapped cancel that logs if query was slow
return timeoutCtx, func() {
elapsed := time.Since(start)
cancel()
// Log slow queries (> 100ms)
if elapsed > 100*time.Millisecond {
log.Warn().
Str("operation", operation).
Dur("elapsed", elapsed).
Dur("timeout", timeout).
Msg("Slow database operation")
}
}
}
// ExecWithTimeout executes a raw SQL query with timeout.
// Returns error if query takes longer than timeout.
func (s *Store) ExecWithTimeout(ctx context.Context, timeout time.Duration, query string, args ...any) error {
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "exec")
defer cancel()
_, err := s.sqlDB.ExecContext(timeoutCtx, query, args...)
if err != nil {
if err == context.DeadlineExceeded {
return fmt.Errorf("query timeout after %v: %s", timeout, query)
}
return err
}
return nil
}
// QueryRowWithTimeout executes a row query with timeout.
func (s *Store) QueryRowWithTimeout(ctx context.Context, timeout time.Duration, query string, args ...any) *sql.Row {
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "query_row")
// Note: cancel will be called when row.Scan() completes or errors
_ = cancel // Caller must ensure proper cleanup
return s.sqlDB.QueryRowContext(timeoutCtx, query, args...)
}
// TransactionWithTimeout wraps a transaction function with timeout handling.
// The transaction is automatically rolled back if the context times out.
func (s *Store) TransactionWithTimeout(ctx context.Context, timeout time.Duration, fn func(*gorm.DB) error) error {
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "transaction")
defer cancel()
return s.DB.WithContext(timeoutCtx).Transaction(func(tx *gorm.DB) error {
// Check context before proceeding
select {
case <-timeoutCtx.Done():
return timeoutCtx.Err()
default:
}
return fn(tx)
})
}
+1
View File
@@ -57,6 +57,7 @@ type PromptReader interface {
GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error)
GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error)
FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool)
FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool)
}
// PromptWriter defines write operations for prompts.
@@ -1 +1 @@
1.23.2
1.24.3
@@ -1 +1 @@
1.23.2
1.24.3
@@ -1 +1 @@
1.23.2
1.24.3
@@ -1 +1 @@
1.23.2
1.24.3
+5 -2
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/sugarme/tokenizer"
@@ -69,8 +70,10 @@ func newBGEModel() (EmbeddingModel, error) {
libPath := filepath.Join(libDir, onnxRuntimeLibName)
ort.SetSharedLibraryPath(libPath)
// Initialize ONNX runtime
if err := ort.InitializeEnvironment(); err != nil {
// Initialize ONNX runtime (idempotent - ignore "already initialized" since
// the ONNX environment is process-global and shared with the reranking service)
err = ort.InitializeEnvironment()
if err != nil && !strings.Contains(err.Error(), "already been initialized") {
return nil, fmt.Errorf("initialize ONNX runtime: %w", err)
}
+290
View File
@@ -0,0 +1,290 @@
// Package maintenance provides scheduled maintenance tasks for claude-mnemonic.
package maintenance
import (
"context"
"sync"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/rs/zerolog"
)
// Service handles scheduled maintenance tasks.
type Service struct {
log zerolog.Logger
lastRunTime time.Time
promptStore *gorm.PromptStore
store *gorm.Store
vectorCleanupFn func(ctx context.Context, deletedIDs []int64)
config *config.Config
summaryStore *gorm.SummaryStore
stopCh chan struct{}
doneCh chan struct{}
observationStore *gorm.ObservationStore
lastRunDuration time.Duration
totalCleanedObs int64
totalOptimizeRun int64
mu sync.Mutex
running bool
}
// NewService creates a new maintenance service.
func NewService(
store *gorm.Store,
observationStore *gorm.ObservationStore,
summaryStore *gorm.SummaryStore,
promptStore *gorm.PromptStore,
vectorCleanupFn func(ctx context.Context, deletedIDs []int64),
cfg *config.Config,
log zerolog.Logger,
) *Service {
return &Service{
store: store,
observationStore: observationStore,
summaryStore: summaryStore,
promptStore: promptStore,
vectorCleanupFn: vectorCleanupFn,
config: cfg,
log: log.With().Str("component", "maintenance").Logger(),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// Start begins the maintenance loop.
func (s *Service) Start(ctx context.Context) {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
close(s.doneCh)
}()
if !s.config.MaintenanceEnabled {
s.log.Info().Msg("Maintenance disabled, not starting scheduler")
return
}
interval := max(time.Duration(s.config.MaintenanceIntervalHours)*time.Hour, time.Hour)
s.log.Info().
Dur("interval", interval).
Int("retention_days", s.config.ObservationRetentionDays).
Bool("cleanup_stale", s.config.CleanupStaleObservations).
Msg("Starting maintenance scheduler")
// Initial run after 5 minutes (allow system to stabilize)
time.Sleep(5 * time.Minute)
s.runMaintenance(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
s.log.Info().Msg("Maintenance shutting down due to context cancellation")
return
case <-s.stopCh:
s.log.Info().Msg("Maintenance shutting down due to stop signal")
return
case <-ticker.C:
s.runMaintenance(ctx)
}
}
}
// Stop signals the maintenance service to stop.
func (s *Service) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
close(s.stopCh)
}
// Wait waits for the maintenance service to finish.
func (s *Service) Wait() {
<-s.doneCh
}
// runMaintenance executes all maintenance tasks.
func (s *Service) runMaintenance(ctx context.Context) {
start := time.Now()
s.log.Info().Msg("Starting maintenance run")
var totalCleaned int64
// Task 1: Clean up old observations by age
if s.config.ObservationRetentionDays > 0 {
cleaned, err := s.cleanupOldObservations(ctx)
if err != nil {
s.log.Error().Err(err).Msg("Failed to cleanup old observations")
} else {
totalCleaned += cleaned
s.log.Info().Int64("cleaned", cleaned).Msg("Cleaned old observations by age")
}
}
// Task 2: Clean up stale observations
if s.config.CleanupStaleObservations {
cleaned, err := s.cleanupStaleObservations(ctx)
if err != nil {
s.log.Error().Err(err).Msg("Failed to cleanup stale observations")
} else {
totalCleaned += cleaned
s.log.Info().Int64("cleaned", cleaned).Msg("Cleaned stale observations")
}
}
// Task 3: Optimize database
if err := s.store.Optimize(ctx); err != nil {
s.log.Error().Err(err).Msg("Failed to optimize database")
} else {
s.totalOptimizeRun++
}
// Task 4: Clean up old prompts (keep last 1000 per session)
cleanedPrompts, err := s.cleanupOldPrompts(ctx)
if err != nil {
s.log.Error().Err(err).Msg("Failed to cleanup old prompts")
} else if cleanedPrompts > 0 {
s.log.Info().Int64("cleaned", cleanedPrompts).Msg("Cleaned old prompts")
}
// Update metrics
s.mu.Lock()
s.lastRunTime = time.Now()
s.lastRunDuration = time.Since(start)
s.totalCleanedObs += totalCleaned
s.mu.Unlock()
s.log.Info().
Dur("duration", time.Since(start)).
Int64("observations_cleaned", totalCleaned).
Msg("Maintenance run completed")
}
// cleanupOldObservations deletes observations older than the retention period.
func (s *Service) cleanupOldObservations(ctx context.Context) (int64, error) {
cutoffEpoch := time.Now().AddDate(0, 0, -s.config.ObservationRetentionDays).Unix()
// Get IDs of old observations
var deletedIDs []int64
err := s.store.GetDB().WithContext(ctx).
Model(&gorm.Observation{}).
Where("created_at_epoch < ?", cutoffEpoch).
Pluck("id", &deletedIDs).Error
if err != nil {
return 0, err
}
if len(deletedIDs) == 0 {
return 0, nil
}
// Delete in batches to avoid long transactions
batchSize := 100
for i := 0; i < len(deletedIDs); i += batchSize {
end := min(i+batchSize, len(deletedIDs))
batch := deletedIDs[i:end]
if err := s.store.GetDB().WithContext(ctx).
Where("id IN ?", batch).
Delete(&gorm.Observation{}).Error; err != nil {
return int64(i), err
}
// Sync vector DB deletions
if s.vectorCleanupFn != nil {
s.vectorCleanupFn(ctx, batch)
}
}
return int64(len(deletedIDs)), nil
}
// cleanupStaleObservations deletes observations marked as stale.
func (s *Service) cleanupStaleObservations(ctx context.Context) (int64, error) {
// Get IDs of stale observations (is_superseded = true)
var deletedIDs []int64
err := s.store.GetDB().WithContext(ctx).
Model(&gorm.Observation{}).
Where("is_superseded = ?", true).
Pluck("id", &deletedIDs).Error
if err != nil {
return 0, err
}
if len(deletedIDs) == 0 {
return 0, nil
}
// Delete in batches
batchSize := 100
for i := 0; i < len(deletedIDs); i += batchSize {
end := min(i+batchSize, len(deletedIDs))
batch := deletedIDs[i:end]
if err := s.store.GetDB().WithContext(ctx).
Where("id IN ?", batch).
Delete(&gorm.Observation{}).Error; err != nil {
return int64(i), err
}
// Sync vector DB deletions
if s.vectorCleanupFn != nil {
s.vectorCleanupFn(ctx, batch)
}
}
return int64(len(deletedIDs)), nil
}
// cleanupOldPrompts removes old prompts keeping only the most recent per session.
func (s *Service) cleanupOldPrompts(ctx context.Context) (int64, error) {
// Delete prompts older than 30 days that aren't the most recent in their session
cutoffEpoch := time.Now().AddDate(0, 0, -30).Unix()
result := s.store.GetDB().WithContext(ctx).
Where("created_at_epoch < ?", cutoffEpoch).
Delete(&gorm.UserPrompt{})
return result.RowsAffected, result.Error
}
// Stats returns maintenance statistics.
func (s *Service) Stats() map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
return map[string]any{
"enabled": s.config.MaintenanceEnabled,
"interval_hours": s.config.MaintenanceIntervalHours,
"retention_days": s.config.ObservationRetentionDays,
"cleanup_stale": s.config.CleanupStaleObservations,
"last_run": s.lastRunTime,
"last_duration_ms": s.lastRunDuration.Milliseconds(),
"total_cleaned_obs": s.totalCleanedObs,
"total_optimizes": s.totalOptimizeRun,
"running": s.running,
}
}
// RunNow triggers an immediate maintenance run.
func (s *Service) RunNow(ctx context.Context) {
go s.runMaintenance(ctx)
}
+1450 -215
View File
File diff suppressed because it is too large Load Diff
+1960 -243
View File
File diff suppressed because it is too large Load Diff
+72 -5
View File
@@ -3,6 +3,9 @@ package pattern
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
@@ -21,6 +24,8 @@ type DetectorConfig struct {
AnalysisInterval time.Duration
// MaxPatternsToTrack is the maximum number of active patterns.
MaxPatternsToTrack int
// MaxCandidates is the maximum number of candidates to track (LRU eviction).
MaxCandidates int
}
// DefaultConfig returns the default detector configuration.
@@ -30,6 +35,7 @@ func DefaultConfig() DetectorConfig {
MinFrequencyForPattern: 2, // At least 2 occurrences to form a pattern
AnalysisInterval: 5 * time.Minute,
MaxPatternsToTrack: 1000,
MaxCandidates: 500, // Prevent unbounded growth
}
}
@@ -195,7 +201,24 @@ func (d *Detector) AnalyzeObservation(ctx context.Context, obs *models.Observati
}
}
} else {
// Create new candidate
// Create new candidate - with immediate size check to prevent unbounded growth
// between periodic cleanups (which run every 5 minutes)
if d.config.MaxCandidates > 0 && len(d.candidates) >= d.config.MaxCandidates {
// Evict oldest candidate immediately rather than waiting for periodic cleanup
var oldestKey string
var oldestTime int64 = time.Now().UnixMilli()
for k, c := range d.candidates {
if c.lastSeenEpoch < oldestTime {
oldestTime = c.lastSeenEpoch
oldestKey = k
}
}
if oldestKey != "" {
delete(d.candidates, oldestKey)
log.Debug().Str("evicted_key", oldestKey).Msg("Evicted oldest candidate to make room")
}
}
patternType := models.DetectPatternType(obs.Concepts, obs.Title.String, obs.Narrative.String)
d.candidates[candidateKey] = &candidatePattern{
signature: signature,
@@ -299,25 +322,65 @@ func (d *Detector) AnalyzeRecentObservations(ctx context.Context) error {
return nil
}
// cleanupOldCandidates removes candidates that haven't been seen recently.
// cleanupOldCandidates removes candidates that haven't been seen recently
// and enforces the max candidates limit using LRU eviction.
func (d *Detector) cleanupOldCandidates() {
d.candidatesMu.Lock()
defer d.candidatesMu.Unlock()
threshold := time.Now().Add(-7 * 24 * time.Hour).UnixMilli()
// First pass: remove expired candidates
for key, candidate := range d.candidates {
if candidate.lastSeenEpoch < threshold {
delete(d.candidates, key)
}
}
// Second pass: enforce max candidates limit using LRU eviction
if d.config.MaxCandidates > 0 && len(d.candidates) > d.config.MaxCandidates {
// Find oldest candidates to evict using O(n log n) sort instead of O(n²) selection sort
type keyAge struct {
key string
age int64
}
candidates := make([]keyAge, 0, len(d.candidates))
for k, c := range d.candidates {
candidates = append(candidates, keyAge{k, c.lastSeenEpoch})
}
// Sort by age ascending (oldest first) - O(n log n)
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].age < candidates[j].age
})
// Delete oldest entries
toEvict := len(d.candidates) - d.config.MaxCandidates
for i := 0; i < toEvict; i++ {
delete(d.candidates, candidates[i].key)
}
log.Debug().Int("evicted", toEvict).Int("remaining", len(d.candidates)).Msg("Evicted old pattern candidates (LRU)")
}
}
// CandidateCount returns the current number of pattern candidates.
func (d *Detector) CandidateCount() int {
d.candidatesMu.RLock()
defer d.candidatesMu.RUnlock()
return len(d.candidates)
}
// GetPatternInsight returns a formatted insight string for a pattern.
// GetPatternInsight returns a formatted insight string for a pattern.
func (d *Detector) GetPatternInsight(ctx context.Context, patternID int64) (string, error) {
pattern, err := d.patternStore.GetPatternByID(ctx, patternID)
if err != nil {
return "", err
}
if pattern == nil {
return "", fmt.Errorf("pattern not found: %d", patternID)
}
return formatPatternInsight(pattern), nil
}
@@ -334,11 +397,15 @@ func generateCandidateKey(signature []string) string {
if len(signature) == 0 {
return ""
}
key := ""
// Use strings.Builder to avoid O(n²) string concatenation
var b strings.Builder
// Pre-allocate: estimate average signature element is 10 chars + separator
b.Grow(len(signature) * 11)
for _, s := range signature {
key += s + "|"
b.WriteString(s)
b.WriteByte('|')
}
return key
return b.String()
}
// generatePatternName creates a human-readable name for a pattern.
+563
View File
@@ -328,6 +328,26 @@ func TestDefaultConfig(t *testing.T) {
}
}
func TestDefaultConfig_AllFieldsValid(t *testing.T) {
config := DefaultConfig()
if config.MinMatchScore != 0.3 {
t.Errorf("MinMatchScore = %f, want 0.3", config.MinMatchScore)
}
if config.MinFrequencyForPattern != 2 {
t.Errorf("MinFrequencyForPattern = %d, want 2", config.MinFrequencyForPattern)
}
if config.AnalysisInterval != 5*time.Minute {
t.Errorf("AnalysisInterval = %v, want 5m", config.AnalysisInterval)
}
if config.MaxPatternsToTrack != 1000 {
t.Errorf("MaxPatternsToTrack = %d, want 1000", config.MaxPatternsToTrack)
}
if config.MaxCandidates != 500 {
t.Errorf("MaxCandidates = %d, want 500", config.MaxCandidates)
}
}
func TestGeneratePatternName(t *testing.T) {
tests := []struct {
patternType models.PatternType
@@ -352,6 +372,85 @@ func TestGeneratePatternName(t *testing.T) {
}
}
func TestGeneratePatternName_EdgeCases(t *testing.T) {
tests := []struct {
name string
ptype models.PatternType
title string
want string
signature []string
}{
{
name: "with title uses title directly",
ptype: models.PatternTypeBug,
signature: []string{"ignored"},
title: "Custom Title",
want: "Custom Title",
},
{
name: "long title generates from signature",
ptype: models.PatternTypeBug,
signature: []string{"sig1", "sig2"},
title: "This is a very long title that exceeds sixty characters and should be ignored",
want: "Bug Pattern: sig1, sig2",
},
{
name: "empty signature returns Unnamed",
ptype: models.PatternTypeBug,
signature: []string{},
title: "",
want: "Bug Pattern: Unnamed",
},
{
name: "single signature element",
ptype: models.PatternTypeRefactor,
signature: []string{"single"},
title: "",
want: "Refactor Pattern: single",
},
{
name: "more than 3 signature elements truncates",
ptype: models.PatternTypeBestPractice,
signature: []string{"a", "b", "c", "d", "e"},
title: "",
want: "Best Practice: a, b, c",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generatePatternName(tt.ptype, tt.signature, tt.title)
if got != tt.want {
t.Errorf("generatePatternName() = %q, want %q", got, tt.want)
}
})
}
}
func TestGeneratePatternName_AllTypes(t *testing.T) {
tests := []struct {
ptype models.PatternType
wantPrefix string
}{
{models.PatternTypeBug, "Bug Pattern:"},
{models.PatternTypeRefactor, "Refactor Pattern:"},
{models.PatternTypeArchitecture, "Architecture Pattern:"},
{models.PatternTypeAntiPattern, "Anti-Pattern:"},
{models.PatternTypeBestPractice, "Best Practice:"},
{models.PatternType("unknown"), "test"}, // Unknown type has empty prefix, starts with first signature element
}
for _, tt := range tests {
t.Run(string(tt.ptype), func(t *testing.T) {
name := generatePatternName(tt.ptype, []string{"test", "sig"}, "")
if !hasPrefix(name, tt.wantPrefix) {
t.Errorf("Expected prefix %q for type %s, got: %s",
tt.wantPrefix, tt.ptype, name)
}
})
}
}
func TestFormatPatternInsight(t *testing.T) {
// Pattern without recommendation
pattern1 := &models.Pattern{
@@ -386,6 +485,470 @@ func TestFormatPatternInsight(t *testing.T) {
}
}
func TestFormatPatternInsight_AllTypes(t *testing.T) {
types := []struct {
ptype models.PatternType
contains string
}{
{models.PatternTypeBug, "bug pattern"},
{models.PatternTypeRefactor, "recognized pattern"}, // Falls to default case
{models.PatternTypeArchitecture, "recognized pattern"}, // Falls to default case
{models.PatternTypeAntiPattern, "anti-pattern"},
{models.PatternTypeBestPractice, "best practice"},
{models.PatternType("unknown"), "recognized pattern"}, // Falls to default case
}
for _, tt := range types {
t.Run(string(tt.ptype), func(t *testing.T) {
pattern := &models.Pattern{
Type: tt.ptype,
Frequency: 3,
Projects: []string{"proj1"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, tt.contains) {
t.Errorf("Expected insight to contain %q for type %s, got: %s",
tt.contains, tt.ptype, insight)
}
})
}
}
func TestFormatPatternInsight_MultiProject(t *testing.T) {
pattern := &models.Pattern{
Type: models.PatternTypeBug,
Frequency: 10,
Projects: []string{"proj1", "proj2", "proj3"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, "10 times") {
t.Error("Expected frequency in insight")
}
if !containsString(insight, "3 projects") {
t.Error("Expected project count in insight")
}
}
func TestFormatPatternInsight_SingleProject(t *testing.T) {
pattern := &models.Pattern{
Type: models.PatternTypeBestPractice,
Frequency: 5,
Projects: []string{"only-one"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, "5 times") {
t.Error("Expected frequency in insight")
}
// Single project should NOT mention "projects"
if containsString(insight, "projects") {
t.Error("Single project should not mention 'projects'")
}
}
func TestDetector_SetSyncFunc(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
// Initially nil
if detector.syncFunc != nil {
t.Error("Expected syncFunc to be nil initially")
}
// Set sync func
var syncCalled bool
detector.SetSyncFunc(func(p *models.Pattern) {
syncCalled = true
})
if detector.syncFunc == nil {
t.Error("Expected syncFunc to be set")
}
// Verify it can be called
detector.syncFunc(&models.Pattern{})
if !syncCalled {
t.Error("Expected sync function to be called")
}
}
func TestDetector_CandidateCount(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
// Initially zero
if count := detector.CandidateCount(); count != 0 {
t.Errorf("Expected 0 candidates, got %d", count)
}
// Add some candidates
detector.candidates["key1"] = &candidatePattern{}
detector.candidates["key2"] = &candidatePattern{}
if count := detector.CandidateCount(); count != 2 {
t.Errorf("Expected 2 candidates, got %d", count)
}
}
func TestDetector_AnalyzeRecentObservations(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Should not error even with no observations
err := detector.AnalyzeRecentObservations(ctx)
if err != nil {
t.Fatalf("AnalyzeRecentObservations() error = %v", err)
}
}
func TestGenerateCandidateKey(t *testing.T) {
tests := []struct {
name string
want string
signature []string
}{
{
name: "single element",
signature: []string{"error"},
want: "error|",
},
{
name: "multiple elements",
signature: []string{"error", "handling", "nil"},
want: "error|handling|nil|",
},
{
name: "empty signature",
signature: []string{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateCandidateKey(tt.signature)
if got != tt.want {
t.Errorf("generateCandidateKey() = %q, want %q", got, tt.want)
}
})
}
}
func TestGenerateCandidateKey_EdgeCases(t *testing.T) {
tests := []struct {
name string
want string
signature []string
}{
{
name: "nil signature",
signature: nil,
want: "",
},
{
name: "empty strings in signature",
signature: []string{"", ""},
want: "||",
},
{
name: "special characters",
signature: []string{"error|handling", "nil"},
want: "error|handling|nil|",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateCandidateKey(tt.signature)
if got != tt.want {
t.Errorf("generateCandidateKey() = %q, want %q", got, tt.want)
}
})
}
}
func TestItoa(t *testing.T) {
tests := []struct {
want string
input int
}{
{"0", 0},
{"1", 1},
{"10", 10},
{"123", 123},
{"-1", -1},
{"-123", -123},
{"1000000", 1000000},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := itoa(tt.input)
if got != tt.want {
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestItoa_EdgeCases(t *testing.T) {
tests := []struct {
want string
input int
}{
{"0", 0},
{"0", -0},
{"1", 1},
{"-1", -1},
{"9", 9},
{"10", 10},
{"99", 99},
{"100", 100},
{"999", 999},
{"1000", 1000},
{"-999", -999},
{"-1000", -1000},
{"2147483647", 2147483647}, // Max int32
{"-2147483647", -2147483647}, // Min int32 + 1
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := itoa(tt.input)
if got != tt.want {
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestDetectionResult_ZeroValue(t *testing.T) {
result := &DetectionResult{}
if result.MatchedPattern != nil {
t.Error("Zero value should have nil MatchedPattern")
}
if result.MatchScore != 0 {
t.Error("Zero value should have 0 MatchScore")
}
if result.IsNewPattern {
t.Error("Zero value should have false IsNewPattern")
}
}
func TestCandidatePattern_Fields(t *testing.T) {
candidate := &candidatePattern{
patternType: models.PatternTypeBug,
title: "Test Title",
signature: []string{"sig1", "sig2"},
observationIDs: []int64{1, 2, 3},
projects: []string{"proj1", "proj2"},
lastSeenEpoch: time.Now().UnixMilli(),
}
if candidate.patternType != models.PatternTypeBug {
t.Error("Wrong pattern type")
}
if candidate.title != "Test Title" {
t.Error("Wrong title")
}
if len(candidate.signature) != 2 {
t.Error("Wrong signature length")
}
if len(candidate.observationIDs) != 3 {
t.Error("Wrong observationIDs length")
}
if len(candidate.projects) != 2 {
t.Error("Wrong projects length")
}
}
func TestDetector_AnalyzeObservation_EmptySignature(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Create observation with empty concepts/title/narrative
obs := &models.Observation{
ID: 1,
SDKSessionID: "test-session",
Project: "test-project",
Scope: models.ScopeProject,
Type: models.ObsTypeBugfix,
// All fields that would create signature are empty
}
result, err := detector.AnalyzeObservation(ctx, obs)
if err != nil {
t.Fatalf("AnalyzeObservation() error = %v", err)
}
// Should return empty result for empty signature
if result.MatchedPattern != nil {
t.Error("Expected nil pattern for empty signature")
}
}
func TestDetector_AnalyzeObservation_CandidateEviction(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MaxCandidates = 2 // Very small for testing
config.MinFrequencyForPattern = 10 // High so nothing gets promoted
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Add observations with different signatures until we exceed MaxCandidates
obs1 := createTestObservation(1, "First", []string{"first", "unique"})
obs2 := createTestObservation(2, "Second", []string{"second", "unique"})
obs3 := createTestObservation(3, "Third", []string{"third", "unique"})
// Analyze all observations
_, _ = detector.AnalyzeObservation(ctx, obs1)
time.Sleep(10 * time.Millisecond) // Small delay so timestamps differ
_, _ = detector.AnalyzeObservation(ctx, obs2)
time.Sleep(10 * time.Millisecond)
_, _ = detector.AnalyzeObservation(ctx, obs3)
// Should have at most MaxCandidates
if count := detector.CandidateCount(); count > config.MaxCandidates {
t.Errorf("Expected at most %d candidates, got %d", config.MaxCandidates, count)
}
}
func TestDetector_PromoteCandidateWithSyncFunc(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MinFrequencyForPattern = 2
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Set up sync function to track calls
var syncedPattern *models.Pattern
detector.SetSyncFunc(func(p *models.Pattern) {
syncedPattern = p
})
// Create two similar observations to trigger pattern promotion
obs1 := createTestObservation(1, "Sync Test", []string{"sync", "test"})
obs2 := createTestObservation(2, "Sync Test", []string{"sync", "test"})
_, _ = detector.AnalyzeObservation(ctx, obs1)
result, _ := detector.AnalyzeObservation(ctx, obs2)
if result.MatchedPattern == nil {
t.Fatal("Expected pattern to be created")
}
if syncedPattern == nil {
t.Error("Expected sync function to be called")
}
if syncedPattern != nil && syncedPattern.Name != result.MatchedPattern.Name {
t.Errorf("Synced pattern name mismatch: got %s, want %s",
syncedPattern.Name, result.MatchedPattern.Name)
}
}
func TestDetector_AnalyzeObservation_UpdateExistingCandidate(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MinFrequencyForPattern = 5 // High enough that we don't promote
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Create observations with same signature
obs1 := createTestObservation(1, "Update Test", []string{"update", "test"})
obs2 := createTestObservation(2, "Update Test", []string{"update", "test"})
obs2.Project = "different-project"
// Analyze first observation
_, _ = detector.AnalyzeObservation(ctx, obs1)
// Check candidate count
if count := detector.CandidateCount(); count != 1 {
t.Errorf("Expected 1 candidate after first obs, got %d", count)
}
// Analyze second observation
_, _ = detector.AnalyzeObservation(ctx, obs2)
// Still 1 candidate (same signature)
if count := detector.CandidateCount(); count != 1 {
t.Errorf("Expected 1 candidate after second obs, got %d", count)
}
// Check that candidate has both projects
key := generateCandidateKey([]string{"update", "test"})
candidate := detector.candidates[key]
if candidate == nil {
t.Fatal("Expected candidate to exist")
}
if len(candidate.projects) != 2 {
t.Errorf("Expected 2 projects, got %d", len(candidate.projects))
}
}
func TestDetector_GetPatternInsight_NotFound(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Try to get insight for non-existent pattern
_, err := detector.GetPatternInsight(ctx, 99999)
if err == nil {
t.Error("Expected error for non-existent pattern")
}
}
// Helper functions
func setupTestStore(t *testing.T) *gorm.Store {
+98
View File
@@ -0,0 +1,98 @@
// Package privacy provides utilities for protecting sensitive data.
package privacy
import (
"regexp"
"slices"
"strings"
)
// secretPatterns contains compiled regular expressions for detecting secrets.
// These patterns are designed to catch common secret formats with minimal false positives.
var secretPatterns = []*regexp.Regexp{
// API keys with common prefixes
regexp.MustCompile(`(?i)(api[_-]?key|apikey)\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?`),
// Passwords in configuration
regexp.MustCompile(`(?i)(password|passwd|pwd)\s*[:=]\s*['"][^'"]{8,}['"]`),
// Secret tokens
regexp.MustCompile(`(?i)(secret[_-]?key|secret[_-]?token|auth[_-]?token)\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?`),
// OpenAI API keys
regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`),
// Anthropic API keys
regexp.MustCompile(`sk-ant-[a-zA-Z0-9-]{20,}`),
// GitHub tokens
regexp.MustCompile(`gh[pous]_[a-zA-Z0-9]{36,}`),
regexp.MustCompile(`github_pat_[a-zA-Z0-9_]{22,}`),
// AWS keys
regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
regexp.MustCompile(`(?i)aws[_-]?secret[_-]?access[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9/+=]{40}['"]?`),
// Private keys (PEM format indicators)
regexp.MustCompile(`-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`),
// JWT tokens (base64.base64.base64 format)
regexp.MustCompile(`eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+`),
// Generic secret assignment patterns
regexp.MustCompile(`(?i)bearer\s+[a-zA-Z0-9_-]{20,}`),
}
// ContainsSecrets checks if the given text contains any patterns that look like secrets.
// Returns true if potential secrets are detected.
func ContainsSecrets(text string) bool {
if text == "" {
return false
}
for _, pattern := range secretPatterns {
if pattern.MatchString(text) {
return true
}
}
return false
}
// RedactSecrets replaces detected secrets with a redaction marker.
// This allows the text to be stored while protecting sensitive data.
func RedactSecrets(text string) string {
if text == "" {
return text
}
result := text
for _, pattern := range secretPatterns {
result = pattern.ReplaceAllStringFunc(result, func(match string) string {
// Preserve the key name, redact only the value
if idx := strings.Index(match, "="); idx != -1 {
return match[:idx+1] + "[REDACTED]"
}
if idx := strings.Index(match, ":"); idx != -1 {
return match[:idx+1] + "[REDACTED]"
}
// For standalone secrets, show just the prefix
if len(match) > 8 {
return match[:4] + "...[REDACTED]"
}
return "[REDACTED]"
})
}
return result
}
// SanitizeObservation checks multiple fields of an observation for secrets.
// Returns true if any secrets were found.
// This function is used as a validation gate before storing observations.
func SanitizeObservation(narrative string, facts []string) bool {
if ContainsSecrets(narrative) {
return true
}
return slices.ContainsFunc(facts, func(fact string) bool {
return ContainsSecrets(fact)
})
}
+213
View File
@@ -0,0 +1,213 @@
package privacy
import (
"testing"
)
func TestContainsSecrets(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "empty string",
input: "",
expected: false,
},
{
name: "normal text",
input: "This is just some regular text about a bug fix",
expected: false,
},
{
name: "API key pattern",
input: "api_key=abc123def456ghi789jkl012mno345pqr678",
expected: true,
},
{
name: "api-key with dash",
input: `api-key: "abc123def456ghi789jkl012mno"`,
expected: true,
},
{
name: "password in config",
input: `password="super_secret_password_123"`,
expected: true,
},
{
name: "OpenAI key format",
input: "sk-abc123def456ghi789jkl012mno345pqr678",
expected: true,
},
{
name: "Anthropic key format",
input: "sk-ant-api03-abc123def456ghi789jkl012mno345",
expected: true,
},
{
name: "GitHub PAT",
input: "ghp_1234567890abcdefghijklmnopqrstuvwxyz",
expected: true,
},
{
name: "GitHub PAT new format",
input: "github_pat_12ABCDEFGHIJ3456789abc_defghijklmno",
expected: true,
},
{
name: "AWS access key",
input: "AKIAIOSFODNN7EXAMPLE",
expected: true,
},
{
name: "Private key header",
input: "-----BEGIN RSA PRIVATE KEY-----",
expected: true,
},
{
name: "JWT token",
input: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
expected: true,
},
{
name: "bearer token",
input: "Bearer abc123def456ghi789jkl012mno345",
expected: true,
},
{
name: "secret_key in code",
input: `secret_key = "my_super_secret_token_here"`,
expected: true,
},
{
name: "short password is not detected",
input: `password="short"`,
expected: false, // Too short to trigger
},
{
name: "word password in sentence",
input: "The password field should be validated",
expected: false,
},
{
name: "word api in code",
input: "The API returns JSON data",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ContainsSecrets(tt.input)
if result != tt.expected {
t.Errorf("ContainsSecrets(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
func TestRedactSecrets(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty string",
input: "",
expected: "",
},
{
name: "no secrets",
input: "This is safe text",
expected: "This is safe text",
},
{
name: "API key gets redacted",
input: "api_key=abc123def456ghi789jkl012mno345pqr678",
expected: "api_key=[REDACTED]",
},
{
name: "OpenAI key gets redacted",
input: "The key is sk-abc123def456ghi789jkl012mno345pqr678",
expected: "The key is sk-a...[REDACTED]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RedactSecrets(tt.input)
if result != tt.expected {
t.Errorf("RedactSecrets(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestSanitizeObservation(t *testing.T) {
tests := []struct {
name string
narrative string
facts []string
expected bool
}{
{
name: "clean observation",
narrative: "Fixed a bug in the login flow",
facts: []string{"Users can now log in", "Session management improved"},
expected: false,
},
{
name: "secret in narrative",
narrative: "Set API key api_key=abc123def456ghi789jkl012mno345",
facts: []string{"Configuration updated"},
expected: true,
},
{
name: "secret in facts",
narrative: "Updated configuration",
facts: []string{"Added api_key=abc123def456ghi789jkl012mno345"},
expected: true,
},
{
name: "empty facts",
narrative: "Clean narrative",
facts: []string{},
expected: false,
},
{
name: "nil facts",
narrative: "Clean narrative",
facts: nil,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SanitizeObservation(tt.narrative, tt.facts)
if result != tt.expected {
t.Errorf("SanitizeObservation() = %v, want %v", result, tt.expected)
}
})
}
}
func BenchmarkContainsSecrets(b *testing.B) {
text := "This is a normal piece of text that does not contain any secrets or sensitive information"
b.ResetTimer()
for i := 0; i < b.N; i++ {
ContainsSecrets(text)
}
}
func BenchmarkContainsSecretsWithSecret(b *testing.B) {
text := "api_key=abc123def456ghi789jkl012mno345pqr678"
b.ResetTimer()
for i := 0; i < b.N; i++ {
ContainsSecrets(text)
}
}
+7 -36
View File
@@ -36,6 +36,13 @@ func NewCalculator(config *models.ScoringConfig) *Calculator {
// - ConceptContrib = sum(concept_weights) × concept_weight_factor
// - RetrievalContrib = log2(retrieval_count + 1) × 0.1 × retrieval_weight
func (c *Calculator) Calculate(obs *models.Observation, now time.Time) float64 {
return c.CalculateComponents(obs, now).FinalScore
}
// CalculateComponents returns the individual components of the importance score.
// Useful for debugging and explaining scores to users.
// This is the core calculation method - Calculate() delegates to this.
func (c *Calculator) CalculateComponents(obs *models.Observation, now time.Time) ScoreComponents {
// 1. Get base type weight
typeWeight := models.TypeBaseScore(obs.Type)
@@ -75,42 +82,6 @@ func (c *Calculator) Calculate(obs *models.Observation, now time.Time) float64 {
finalScore = c.config.MinScore
}
return finalScore
}
// CalculateComponents returns the individual components of the importance score.
// Useful for debugging and explaining scores to users.
func (c *Calculator) CalculateComponents(obs *models.Observation, now time.Time) ScoreComponents {
typeWeight := models.TypeBaseScore(obs.Type)
ageDays := now.Sub(time.UnixMilli(obs.CreatedAtEpoch)).Hours() / 24.0
if ageDays < 0 {
ageDays = 0
}
recencyDecay := math.Pow(0.5, ageDays/c.config.RecencyHalfLifeDays)
coreScore := 1.0 * typeWeight * recencyDecay
feedbackContrib := float64(obs.UserFeedback) * c.config.FeedbackWeight
conceptBoost := 0.0
for _, concept := range obs.Concepts {
if weight, ok := c.config.ConceptWeights[concept]; ok {
conceptBoost += weight
}
}
conceptContrib := conceptBoost * c.config.ConceptWeight
retrievalContrib := 0.0
if obs.RetrievalCount > 0 {
retrievalBoost := math.Log2(float64(obs.RetrievalCount)+1) * 0.1
retrievalContrib = retrievalBoost * c.config.RetrievalWeight
}
finalScore := coreScore + feedbackContrib + conceptContrib + retrievalContrib
if finalScore < c.config.MinScore {
finalScore = c.config.MinScore
}
return ScoreComponents{
TypeWeight: typeWeight,
RecencyDecay: recencyDecay,
+9 -14
View File
@@ -3,7 +3,9 @@ package expansion
import (
"context"
"math"
"regexp"
"sort"
"strings"
"sync"
@@ -281,14 +283,10 @@ func (e *Expander) expandByVocabulary(ctx context.Context, query string, minSimi
return nil
}
// Sort by score (descending) using bubble sort
for i := 0; i < len(similar)-1; i++ {
for j := i + 1; j < len(similar); j++ {
if similar[j].score > similar[i].score {
similar[i], similar[j] = similar[j], similar[i]
}
}
}
// Sort by score (descending) using Go's standard sort - O(n log n)
sort.Slice(similar, func(i, j int) bool {
return similar[i].score > similar[j].score
})
// Create expansion by combining top similar terms with query
var expansions []ExpandedQuery
@@ -427,16 +425,13 @@ func cosineSimilarity(a, b []float32) float64 {
return dot / (sqrt(normA) * sqrt(normB))
}
// sqrt is a simple square root implementation.
// sqrt uses the standard math.Sqrt for better performance and accuracy.
// Returns 0 for non-positive values (original behavior for compatibility).
func sqrt(x float64) float64 {
if x <= 0 {
return 0
}
z := x
for i := 0; i < 10; i++ {
z = (z + x/z) / 2
}
return z
return math.Sqrt(x)
}
// truncate truncates a string to maxLen characters.
+680 -24
View File
@@ -3,19 +3,146 @@ package search
import (
"context"
"hash/fnv"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
)
// multiSpaceRegex matches multiple consecutive whitespace characters.
// Pre-compiled for performance in normalizeQuery.
var multiSpaceRegex = regexp.MustCompile(`\s+`)
// Search configuration constants.
const (
// Cache configuration
defaultCacheTTL = 30 * time.Second // Short TTL for freshness
defaultCacheMaxSize = 200 // Max cached results
cacheEvictionPercent = 10 // Evict 10% when cache is full
cacheEvictionThreshold = 80 // Start eviction scan at 80% capacity
// Latency tracking
latencyHistogramCap = 1000 // Max latency samples for histogram
slowQueryThresholdNs = 100 * 1e6 // 100ms threshold for slow query logging
// Query frequency tracking
maxFrequencyEntries = 1000 // Max queries to track for warming
frequencyEvictionBatch = 100 // Remove 10% when frequency map is full
staleQueryThreshold = 24 * time.Hour // Remove queries older than 24 hours
recentQueryWindow = time.Hour // Only consider queries from last hour for warming
// Cache warming configuration
cacheWarmingInitDelay = 30 * time.Second // Delay before starting warming
cacheWarmingInterval = 20 * time.Second // Run warming cycle every 20 seconds
frequencyCleanupInterval = 5 * time.Minute // Cleanup stale entries every 5 minutes
cacheCleanupInterval = time.Minute // Cleanup expired cache every minute
warmingQueryTimeout = 5 * time.Second // Timeout for warming queries
warmingBatchSize = 5 // Warm top 5 queries per cycle
minRecencyFactor = 0.1 // Minimum recency factor for scoring
// Default query limits
defaultQueryLimit = 20
maxQueryLimit = 100
defaultOrderBy = "date_desc"
// Truncation lengths
queryLogTruncateLen = 50 // Truncate query in logs
titleTruncateLen = 100 // Truncate titles in results
warmingLogTruncateLen = 30 // Truncate query in warming logs
)
// SearchMetrics tracks search performance statistics.
type SearchMetrics struct {
latencyHistogram []int64
TotalSearches int64
VectorSearches int64
FilterSearches int64
TotalLatencyNs int64
VectorLatencyNs int64
FilterLatencyNs int64
CacheHits int64
CoalescedRequests int64
SearchErrors int64
histogramMu sync.Mutex
}
// GetStats returns the current search statistics.
func (m *SearchMetrics) GetStats() map[string]any {
totalSearches := atomic.LoadInt64(&m.TotalSearches)
totalLatency := atomic.LoadInt64(&m.TotalLatencyNs)
vectorSearches := atomic.LoadInt64(&m.VectorSearches)
vectorLatency := atomic.LoadInt64(&m.VectorLatencyNs)
filterSearches := atomic.LoadInt64(&m.FilterSearches)
filterLatency := atomic.LoadInt64(&m.FilterLatencyNs)
avgLatencyMs := float64(0)
if totalSearches > 0 {
avgLatencyMs = float64(totalLatency) / float64(totalSearches) / 1e6
}
avgVectorLatencyMs := float64(0)
if vectorSearches > 0 {
avgVectorLatencyMs = float64(vectorLatency) / float64(vectorSearches) / 1e6
}
avgFilterLatencyMs := float64(0)
if filterSearches > 0 {
avgFilterLatencyMs = float64(filterLatency) / float64(filterSearches) / 1e6
}
return map[string]any{
"total_searches": totalSearches,
"vector_searches": vectorSearches,
"filter_searches": filterSearches,
"cache_hits": atomic.LoadInt64(&m.CacheHits),
"coalesced_requests": atomic.LoadInt64(&m.CoalescedRequests),
"search_errors": atomic.LoadInt64(&m.SearchErrors),
"avg_latency_ms": avgLatencyMs,
"avg_vector_latency_ms": avgVectorLatencyMs,
"avg_filter_latency_ms": avgFilterLatencyMs,
}
}
// Manager provides unified search across SQLite and sqlite-vec.
type Manager struct {
ctx context.Context
searchGroup singleflight.Group
cancel context.CancelFunc
vectorClient *sqlitevec.Client
metrics *SearchMetrics
promptStore *gorm.PromptStore
observationStore *gorm.ObservationStore
summaryStore *gorm.SummaryStore
promptStore *gorm.PromptStore
vectorClient *sqlitevec.Client
resultCache map[string]*cachedResult
queryFrequency map[string]*queryFrequencyInfo
cacheTTL time.Duration
cacheMaxSize int
resultCacheMu sync.RWMutex
queryFrequencyMu sync.RWMutex
}
// queryFrequencyInfo tracks how often a query is used.
type queryFrequencyInfo struct {
lastUsed time.Time
lastCached time.Time
params SearchParams
count int64
}
// cachedResult stores a cached search result with expiry.
type cachedResult struct {
result *UnifiedSearchResult
expiresAt time.Time
}
// NewManager creates a new search manager.
@@ -25,12 +152,463 @@ func NewManager(
promptStore *gorm.PromptStore,
vectorClient *sqlitevec.Client,
) *Manager {
return &Manager{
ctx, cancel := context.WithCancel(context.Background())
m := &Manager{
observationStore: observationStore,
summaryStore: summaryStore,
promptStore: promptStore,
vectorClient: vectorClient,
metrics: &SearchMetrics{latencyHistogram: make([]int64, 0, latencyHistogramCap)},
ctx: ctx,
cancel: cancel,
resultCache: make(map[string]*cachedResult),
cacheTTL: defaultCacheTTL,
cacheMaxSize: defaultCacheMaxSize,
queryFrequency: make(map[string]*queryFrequencyInfo),
}
// Start cache cleanup goroutine
go m.cleanupCacheLoop()
// Start cache warming goroutine
go m.cacheWarmingLoop()
return m
}
// Close stops background goroutines and cleans up resources.
func (m *Manager) Close() {
if m.cancel != nil {
m.cancel()
}
}
// cleanupCacheLoop periodically removes expired cache entries.
func (m *Manager) cleanupCacheLoop() {
ticker := time.NewTicker(cacheCleanupInterval)
defer ticker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C:
m.cleanupExpiredCache()
}
}
}
// cleanupExpiredCache removes expired entries from the cache.
func (m *Manager) cleanupExpiredCache() {
m.resultCacheMu.Lock()
defer m.resultCacheMu.Unlock()
now := time.Now()
for key, cached := range m.resultCache {
if now.After(cached.expiresAt) {
delete(m.resultCache, key)
}
}
}
// cacheWarmingLoop periodically warms the cache for frequently used queries.
func (m *Manager) cacheWarmingLoop() {
// Wait a bit before starting to allow system to stabilize
select {
case <-m.ctx.Done():
return
case <-time.After(cacheWarmingInitDelay):
}
warmingTicker := time.NewTicker(cacheWarmingInterval)
cleanupTicker := time.NewTicker(frequencyCleanupInterval)
defer warmingTicker.Stop()
defer cleanupTicker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-warmingTicker.C:
m.warmFrequentQueries()
case <-cleanupTicker.C:
m.cleanupStaleFrequencyEntries()
}
}
}
// cleanupStaleFrequencyEntries removes query frequency entries older than staleQueryThreshold.
// This prevents memory bloat from queries that haven't been used in a long time.
func (m *Manager) cleanupStaleFrequencyEntries() {
m.queryFrequencyMu.Lock()
now := time.Now()
var keysToDelete []string
for k, v := range m.queryFrequency {
if now.Sub(v.lastUsed) > staleQueryThreshold {
keysToDelete = append(keysToDelete, k)
}
}
for _, k := range keysToDelete {
delete(m.queryFrequency, k)
}
m.queryFrequencyMu.Unlock()
if len(keysToDelete) > 0 {
log.Debug().Int("removed", len(keysToDelete)).Msg("Cleaned up stale query frequency entries")
}
}
// warmFrequentQueries pre-executes frequently used queries to warm the cache.
func (m *Manager) warmFrequentQueries() {
m.queryFrequencyMu.RLock()
// Find top N most frequent queries that aren't recently cached
type queryScore struct {
info *queryFrequencyInfo
key string
score float64
}
candidates := make([]queryScore, 0, len(m.queryFrequency))
now := time.Now()
for key, info := range m.queryFrequency {
// Only consider queries used recently
if now.Sub(info.lastUsed) > recentQueryWindow {
continue
}
// Only warm if not recently cached (cache about to expire or already expired)
timeSinceLastCache := now.Sub(info.lastCached)
if timeSinceLastCache < m.cacheTTL/2 {
continue
}
// Score: frequency * recency factor
recencyFactor := 1.0 - (now.Sub(info.lastUsed).Seconds() / recentQueryWindow.Seconds())
if recencyFactor < minRecencyFactor {
recencyFactor = minRecencyFactor
}
score := float64(info.count) * recencyFactor
candidates = append(candidates, queryScore{key: key, info: info, score: score})
}
m.queryFrequencyMu.RUnlock()
// Sort by score descending using O(n log n) algorithm
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
// Warm top queries
warmCount := min(warmingBatchSize, len(candidates))
for i := range warmCount {
candidate := candidates[i]
ctx, cancel := context.WithTimeout(context.Background(), warmingQueryTimeout)
result, err := m.executeSearch(ctx, candidate.info.params)
cancel()
if err == nil && result != nil {
cacheKey := m.getCacheKey(candidate.info.params)
m.putInCache(cacheKey, result)
// Update last cached time
m.queryFrequencyMu.Lock()
if info, ok := m.queryFrequency[candidate.key]; ok {
info.lastCached = time.Now()
}
m.queryFrequencyMu.Unlock()
log.Debug().
Str("query", truncate(candidate.info.params.Query, warmingLogTruncateLen)).
Float64("score", candidate.score).
Msg("Cache warmed for frequent query")
}
}
}
// trackQueryFrequency records query usage for cache warming decisions.
func (m *Manager) trackQueryFrequency(params SearchParams) {
key := m.getCacheKey(params)
m.queryFrequencyMu.Lock()
if info, ok := m.queryFrequency[key]; ok {
info.count++
info.lastUsed = time.Now()
m.queryFrequencyMu.Unlock()
return // Fast path: no eviction needed
}
m.queryFrequency[key] = &queryFrequencyInfo{
params: params,
count: 1,
lastUsed: time.Now(),
}
// Limit frequency map size to prevent memory bloat
mapLen := len(m.queryFrequency)
if mapLen <= maxFrequencyEntries {
m.queryFrequencyMu.Unlock()
return // Fast path: no eviction needed
}
// Collect keys and times for eviction (still under lock, but fast)
type entry struct {
lastUsed time.Time
key string
}
entries := make([]entry, 0, mapLen)
for k, v := range m.queryFrequency {
entries = append(entries, entry{key: k, lastUsed: v.lastUsed})
}
m.queryFrequencyMu.Unlock()
// Sort outside lock to reduce contention (O(n log n))
sort.Slice(entries, func(i, j int) bool {
return entries[i].lastUsed.Before(entries[j].lastUsed)
})
// Collect keys to delete
deleteCount := min(frequencyEvictionBatch, len(entries))
keysToDelete := make([]string, deleteCount)
for i := range deleteCount {
keysToDelete[i] = entries[i].key
}
// Re-acquire lock only for deletion (brief critical section)
m.queryFrequencyMu.Lock()
for _, k := range keysToDelete {
delete(m.queryFrequency, k)
}
m.queryFrequencyMu.Unlock()
}
// RecentQuery represents a recently executed search query.
type RecentQuery struct {
LastUsed time.Time `json:"last_used"`
Query string `json:"query"`
Project string `json:"project,omitempty"`
Type string `json:"type,omitempty"`
Count int64 `json:"count"`
}
// GetRecentQueries returns the most recent search queries, sorted by last used time.
func (m *Manager) GetRecentQueries(limit int) []RecentQuery {
if limit <= 0 {
limit = defaultQueryLimit
}
if limit > maxQueryLimit {
limit = maxQueryLimit
}
m.queryFrequencyMu.RLock()
defer m.queryFrequencyMu.RUnlock()
// Collect all queries
queries := make([]RecentQuery, 0, len(m.queryFrequency))
for _, info := range m.queryFrequency {
queries = append(queries, RecentQuery{
Query: info.params.Query,
Project: info.params.Project,
Type: info.params.Type,
Count: info.count,
LastUsed: info.lastUsed,
})
}
// Sort by last used (most recent first)
sort.Slice(queries, func(i, j int) bool {
return queries[i].LastUsed.After(queries[j].LastUsed)
})
// Limit results
if len(queries) > limit {
queries = queries[:limit]
}
return queries
}
// GetFrequentQueries returns the most frequently used search queries.
func (m *Manager) GetFrequentQueries(limit int) []RecentQuery {
if limit <= 0 {
limit = defaultQueryLimit
}
if limit > maxQueryLimit {
limit = maxQueryLimit
}
m.queryFrequencyMu.RLock()
defer m.queryFrequencyMu.RUnlock()
// Only include queries used recently
now := time.Now()
queries := make([]RecentQuery, 0, len(m.queryFrequency))
for _, info := range m.queryFrequency {
if now.Sub(info.lastUsed) > recentQueryWindow {
continue
}
queries = append(queries, RecentQuery{
Query: info.params.Query,
Project: info.params.Project,
Type: info.params.Type,
Count: info.count,
LastUsed: info.lastUsed,
})
}
// Sort by count (highest first)
sort.Slice(queries, func(i, j int) bool {
return queries[i].Count > queries[j].Count
})
// Limit results
if len(queries) > limit {
queries = queries[:limit]
}
return queries
}
// normalizeQuery normalizes a search query for consistent cache keys.
// Converts to lowercase, trims whitespace, and collapses multiple spaces.
// Uses pre-compiled regex for O(n) performance instead of O(n*m) loop.
func normalizeQuery(query string) string {
// Lowercase for case-insensitive matching
query = strings.ToLower(query)
// Collapse multiple whitespace into single space using pre-compiled regex
query = multiSpaceRegex.ReplaceAllString(query, " ")
// Trim leading/trailing whitespace (after collapsing)
return strings.TrimSpace(query)
}
// getCacheKey generates a cache key from search params.
// Uses direct string concatenation instead of JSON marshal for better performance.
// Queries are normalized for consistent cache hits across whitespace variations.
func (m *Manager) getCacheKey(params SearchParams) string {
// Normalize query for consistent cache keys
normalizedQuery := normalizeQuery(params.Query)
// Hash directly without intermediate string allocation.
// FNV-64a is fast and collision-safe for cache keys.
h := fnv.New64a()
// Write each field directly to the hasher with separators
h.Write([]byte(normalizedQuery))
h.Write([]byte{'|'})
h.Write([]byte(params.Type))
h.Write([]byte{'|'})
h.Write([]byte(params.Project))
h.Write([]byte{'|'})
h.Write([]byte(params.ObsType))
h.Write([]byte{'|'})
h.Write([]byte(params.Concepts))
h.Write([]byte{'|'})
h.Write([]byte(params.Files))
h.Write([]byte{'|'})
h.Write([]byte(strconv.FormatInt(params.DateStart, 10)))
h.Write([]byte{'|'})
h.Write([]byte(strconv.FormatInt(params.DateEnd, 10)))
h.Write([]byte{'|'})
h.Write([]byte(params.OrderBy))
h.Write([]byte{'|'})
h.Write([]byte(strconv.Itoa(params.Limit)))
h.Write([]byte{'|'})
h.Write([]byte(strconv.Itoa(params.Offset)))
h.Write([]byte{'|'})
h.Write([]byte(params.Format))
h.Write([]byte{'|'})
h.Write([]byte(params.Scope))
h.Write([]byte{'|'})
if params.IncludeGlobal {
h.Write([]byte{'1'})
} else {
h.Write([]byte{'0'})
}
h.Write([]byte{'|'})
if params.ExcludeSuperseded {
h.Write([]byte{'1'})
} else {
h.Write([]byte{'0'})
}
return strconv.FormatUint(h.Sum64(), 36) // Base36 for compact representation
}
// getFromCache retrieves a result from cache if valid.
func (m *Manager) getFromCache(key string) (*UnifiedSearchResult, bool) {
m.resultCacheMu.RLock()
defer m.resultCacheMu.RUnlock()
if cached, ok := m.resultCache[key]; ok {
if time.Now().Before(cached.expiresAt) {
atomic.AddInt64(&m.metrics.CacheHits, 1)
return cached.result, true
}
}
return nil, false
}
// putInCache stores a result in the cache.
// Optimized to skip expensive scans when cache is below capacity threshold.
func (m *Manager) putInCache(key string, result *UnifiedSearchResult) {
m.resultCacheMu.Lock()
defer m.resultCacheMu.Unlock()
now := time.Now()
cacheLen := len(m.resultCache)
// Only scan for expired entries when at threshold+ capacity (amortized cleanup)
evictionThreshold := (m.cacheMaxSize * cacheEvictionThreshold) / 100
if cacheLen >= evictionThreshold {
// Evict expired entries
for k, v := range m.resultCache {
if now.After(v.expiresAt) {
delete(m.resultCache, k)
}
}
cacheLen = len(m.resultCache) // Update after eviction
}
// If still at capacity after removing expired, use simple FIFO-style eviction
// Go map iteration order is random, which provides good cache behavior
if cacheLen >= m.cacheMaxSize {
// Evict percentage using random-order iteration (O(n) single pass)
evictCount := max(m.cacheMaxSize*cacheEvictionPercent/100, 1)
evicted := 0
for k := range m.resultCache {
delete(m.resultCache, k)
evicted++
if evicted >= evictCount {
break
}
}
}
m.resultCache[key] = &cachedResult{
result: result,
expiresAt: now.Add(m.cacheTTL),
}
}
// Metrics returns the search metrics for monitoring.
func (m *Manager) Metrics() *SearchMetrics {
return m.metrics
}
// CacheStats returns current cache statistics.
func (m *Manager) CacheStats() map[string]any {
m.resultCacheMu.RLock()
cacheSize := len(m.resultCache)
m.resultCacheMu.RUnlock()
return map[string]any{
"size": cacheSize,
"max_size": m.cacheMaxSize,
"ttl_sec": m.cacheTTL.Seconds(),
}
}
// ClearCache clears the result cache. Useful for testing or after data changes.
func (m *Manager) ClearCache() {
m.resultCacheMu.Lock()
m.resultCache = make(map[string]*cachedResult)
m.resultCacheMu.Unlock()
}
// SearchParams contains parameters for unified search.
@@ -53,16 +631,17 @@ type SearchParams struct {
}
// SearchResult represents a unified search result.
// Field order optimized for memory alignment (fieldalignment).
type SearchResult struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Project string `json:"project"`
Scope string `json:"scope,omitempty"`
ID int64 `json:"id"`
CreatedAt int64 `json:"created_at_epoch"`
Score float64 `json:"score,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Project string `json:"project"`
Scope string `json:"scope,omitempty"`
ID int64 `json:"id"`
CreatedAt int64 `json:"created_at_epoch"`
Score float64 `json:"score,omitempty"`
}
// UnifiedSearchResult contains the combined search results.
@@ -73,17 +652,69 @@ type UnifiedSearchResult struct {
}
// UnifiedSearch performs a unified search across all document types.
// Uses caching and request coalescing for optimal performance.
func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
start := time.Now()
defer func() {
latency := time.Since(start).Nanoseconds()
atomic.AddInt64(&m.metrics.TotalSearches, 1)
atomic.AddInt64(&m.metrics.TotalLatencyNs, latency)
// Sample latency for histogram (reservoir sampling)
m.metrics.histogramMu.Lock()
if len(m.metrics.latencyHistogram) < latencyHistogramCap {
m.metrics.latencyHistogram = append(m.metrics.latencyHistogram, latency)
}
m.metrics.histogramMu.Unlock()
// Log slow queries
if latency > slowQueryThresholdNs {
log.Warn().
Str("query", truncate(params.Query, queryLogTruncateLen)).
Dur("latency", time.Duration(latency)).
Str("type", params.Type).
Msg("Slow search query")
}
}()
if params.Limit <= 0 {
params.Limit = 20
params.Limit = defaultQueryLimit
}
if params.Limit > 100 {
params.Limit = 100
if params.Limit > maxQueryLimit {
params.Limit = maxQueryLimit
}
if params.OrderBy == "" {
params.OrderBy = "date_desc"
params.OrderBy = defaultOrderBy
}
// Check cache first
cacheKey := m.getCacheKey(params)
if cached, ok := m.getFromCache(cacheKey); ok {
return cached, nil
}
// Use singleflight to coalesce concurrent identical requests
result, err, _ := m.searchGroup.Do(cacheKey, func() (any, error) {
return m.executeSearch(ctx, params)
})
if err != nil {
return nil, err
}
searchResult := result.(*UnifiedSearchResult)
// Cache the result
m.putInCache(cacheKey, searchResult)
// Track query frequency for cache warming
m.trackQueryFrequency(params)
return searchResult, nil
}
// executeSearch performs the actual search without caching/coalescing.
func (m *Manager) executeSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
// If query is provided and vector client is available, use vector search
if params.Query != "" && m.vectorClient != nil && m.vectorClient.IsConnected() {
return m.vectorSearch(ctx, params)
@@ -95,6 +726,13 @@ func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*Unif
// vectorSearch performs semantic search via sqlite-vec.
func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
start := time.Now()
defer func() {
latency := time.Since(start).Nanoseconds()
atomic.AddInt64(&m.metrics.VectorSearches, 1)
atomic.AddInt64(&m.metrics.VectorLatencyNs, latency)
}()
// Build where filter based on search type
var docType sqlitevec.DocType
switch params.Type {
@@ -110,6 +748,7 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
// Query sqlite-vec
vectorResults, err := m.vectorClient.Query(ctx, params.Query, params.Limit*2, where)
if err != nil {
atomic.AddInt64(&m.metrics.SearchErrors, 1)
// Fall back to filter search on error
return m.filterSearch(ctx, params)
}
@@ -125,7 +764,9 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
if len(obsIDs) > 0 && (params.Type == "" || params.Type == "observations") {
obs, err := m.observationStore.GetObservationsByIDs(ctx, obsIDs, params.OrderBy, 0)
if err == nil {
if err != nil {
log.Warn().Err(err).Int("count", len(obsIDs)).Msg("Failed to fetch observations by IDs in vector search")
} else {
for _, o := range obs {
// Skip superseded observations when requested
if params.ExcludeSuperseded && o.IsSuperseded {
@@ -138,7 +779,9 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
if len(summaryIDs) > 0 && (params.Type == "" || params.Type == "sessions") {
summaries, err := m.summaryStore.GetSummariesByIDs(ctx, summaryIDs, params.OrderBy, 0)
if err == nil {
if err != nil {
log.Warn().Err(err).Int("count", len(summaryIDs)).Msg("Failed to fetch summaries by IDs in vector search")
} else {
for _, s := range summaries {
results = append(results, m.summaryToResult(s, params.Format))
}
@@ -147,7 +790,9 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
if len(promptIDs) > 0 && (params.Type == "" || params.Type == "prompts") {
prompts, err := m.promptStore.GetPromptsByIDs(ctx, promptIDs, params.OrderBy, 0)
if err == nil {
if err != nil {
log.Warn().Err(err).Int("count", len(promptIDs)).Msg("Failed to fetch prompts by IDs in vector search")
} else {
for _, p := range prompts {
results = append(results, m.promptToResult(p, params.Format))
}
@@ -168,6 +813,13 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
// filterSearch performs structured filter search via SQLite.
func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
start := time.Now()
defer func() {
latency := time.Since(start).Nanoseconds()
atomic.AddInt64(&m.metrics.FilterSearches, 1)
atomic.AddInt64(&m.metrics.FilterLatencyNs, latency)
}()
var results []SearchResult
// Search observations
@@ -182,7 +834,9 @@ func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*Unifi
obs, err = m.observationStore.GetRecentObservations(ctx, params.Project, params.Limit)
}
if err == nil {
if err != nil {
log.Warn().Err(err).Str("project", params.Project).Msg("Failed to fetch observations in filter search")
} else {
for _, o := range obs {
results = append(results, m.observationToResult(o, params.Format))
}
@@ -192,7 +846,9 @@ func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*Unifi
// Search summaries
if params.Type == "" || params.Type == "sessions" {
summaries, err := m.summaryStore.GetRecentSummaries(ctx, params.Project, params.Limit)
if err == nil {
if err != nil {
log.Warn().Err(err).Str("project", params.Project).Msg("Failed to fetch summaries in filter search")
} else {
for _, s := range summaries {
results = append(results, m.summaryToResult(s, params.Format))
}
@@ -249,7 +905,7 @@ func (m *Manager) observationToResult(obs *models.Observation, format string) Se
Project: obs.Project,
Scope: string(obs.Scope),
CreatedAt: obs.CreatedAtEpoch,
Metadata: map[string]interface{}{
Metadata: map[string]any{
"obs_type": string(obs.Type),
"scope": string(obs.Scope),
},
@@ -275,7 +931,7 @@ func (m *Manager) summaryToResult(summary *models.SessionSummary, format string)
}
if summary.Request.Valid {
result.Title = truncate(summary.Request.String, 100)
result.Title = truncate(summary.Request.String, titleTruncateLen)
}
if format == "full" && summary.Learned.Valid {
@@ -293,7 +949,7 @@ func (m *Manager) promptToResult(prompt *models.UserPromptWithSession, format st
CreatedAt: prompt.CreatedAtEpoch,
}
result.Title = truncate(prompt.PromptText, 100)
result.Title = truncate(prompt.PromptText, titleTruncateLen)
if format == "full" {
result.Content = prompt.PromptText
+707 -28
View File
@@ -5,19 +5,105 @@ import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
)
// embeddingCacheEntry stores a cached embedding with its timestamp.
type embeddingCacheEntry struct {
embedding []float32
timestamp int64 // Unix nano
}
// resultCacheEntry stores cached query results.
type resultCacheEntry struct {
queryHash string
results []QueryResult
timestamp int64
}
// Client provides vector operations via sqlite-vec.
type Client struct {
db *sql.DB
embedSvc *embedding.Service
mu sync.Mutex
embeddingGroup singleflight.Group
resultCache map[string]resultCacheEntry
db *sql.DB
embedSvc *embedding.Service
queryCache map[string]embeddingCacheEntry
stopCleanup chan struct{}
stats CacheStats
cleanupWg sync.WaitGroup
resultCacheTTLNano int64
cacheTTLNano int64
resultCacheMaxSize int
cacheMaxSize int
resultCacheMu sync.RWMutex
queryCacheMu sync.RWMutex
readMu sync.RWMutex
writeMu sync.Mutex
}
// CacheStats tracks cache performance metrics using atomic counters for lock-free updates.
type CacheStats struct {
embeddingHits atomic.Int64
embeddingMisses atomic.Int64
resultHits atomic.Int64
resultMisses atomic.Int64
embeddingEvictions atomic.Int64
resultEvictions atomic.Int64
}
// CacheStatsSnapshot is the exported version of CacheStats for JSON marshaling.
type CacheStatsSnapshot struct {
EmbeddingHits int64 `json:"embedding_hits"`
EmbeddingMisses int64 `json:"embedding_misses"`
ResultHits int64 `json:"result_hits"`
ResultMisses int64 `json:"result_misses"`
EmbeddingEvictions int64 `json:"embedding_evictions"`
ResultEvictions int64 `json:"result_evictions"`
}
// HitRate returns the cache hit rate as a percentage.
func (s CacheStatsSnapshot) HitRate() float64 {
total := s.EmbeddingHits + s.EmbeddingMisses + s.ResultHits + s.ResultMisses
if total == 0 {
return 0
}
hits := s.EmbeddingHits + s.ResultHits
return float64(hits) / float64(total) * 100
}
// HitRate returns the cache hit rate as a percentage.
func (s *CacheStats) HitRate() float64 {
embHits := s.embeddingHits.Load()
embMisses := s.embeddingMisses.Load()
resHits := s.resultHits.Load()
resMisses := s.resultMisses.Load()
total := embHits + embMisses + resHits + resMisses
if total == 0 {
return 0
}
hits := embHits + resHits
return float64(hits) / float64(total) * 100
}
// Snapshot returns a copy of the current stats.
func (s *CacheStats) Snapshot() CacheStatsSnapshot {
return CacheStatsSnapshot{
EmbeddingHits: s.embeddingHits.Load(),
EmbeddingMisses: s.embeddingMisses.Load(),
ResultHits: s.resultHits.Load(),
ResultMisses: s.resultMisses.Load(),
EmbeddingEvictions: s.embeddingEvictions.Load(),
ResultEvictions: s.resultEvictions.Load(),
}
}
// Config holds configuration for the client.
@@ -34,10 +120,23 @@ func NewClient(cfg Config, embedSvc *embedding.Service) (*Client, error) {
return nil, fmt.Errorf("embedding service required")
}
return &Client{
db: cfg.DB,
embedSvc: embedSvc,
}, nil
c := &Client{
db: cfg.DB,
embedSvc: embedSvc,
queryCache: make(map[string]embeddingCacheEntry),
cacheMaxSize: 500, // Cache up to 500 query embeddings
cacheTTLNano: 5 * 60 * 1e9, // 5 minute TTL for embeddings
resultCache: make(map[string]resultCacheEntry),
resultCacheMaxSize: 200, // Cache up to 200 search results
resultCacheTTLNano: 60 * 1e9, // 1 minute TTL for results (shorter since data changes)
stopCleanup: make(chan struct{}),
}
// Start background cache cleanup goroutine
c.cleanupWg.Add(1)
go c.cacheCleanupLoop()
return c, nil
}
// AddDocuments adds documents with their embeddings to the vector store.
@@ -46,8 +145,8 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
c.writeMu.Lock()
defer c.writeMu.Unlock()
// Generate embeddings for all documents
texts := make([]string, len(docs))
@@ -75,7 +174,10 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
}
defer func() {
if err != nil {
_ = tx.Rollback()
if rbErr := tx.Rollback(); rbErr != nil {
// Rollback failure is serious - indicates potential data corruption risk
log.Error().Err(rbErr).Err(err).Msg("Failed to rollback transaction after error - data may be inconsistent")
}
}
}()
@@ -118,6 +220,9 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
return fmt.Errorf("commit transaction: %w", err)
}
// Invalidate result cache since data changed
c.InvalidateResultCache()
log.Debug().Int("count", len(docs)).Str("model", modelVersion).Msg("Added documents to sqlite-vec")
return nil
}
@@ -128,12 +233,12 @@ func (c *Client) DeleteDocuments(ctx context.Context, ids []string) error {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
c.writeMu.Lock()
defer c.writeMu.Unlock()
// Build placeholder string
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
@@ -148,17 +253,25 @@ func (c *Client) DeleteDocuments(ctx context.Context, ids []string) error {
return fmt.Errorf("delete documents: %w", err)
}
// Invalidate result cache since data changed
c.InvalidateResultCache()
log.Debug().Int("count", len(ids)).Msg("Deleted documents from sqlite-vec")
return nil
}
// Query performs a vector similarity search.
func (c *Client) Query(ctx context.Context, query string, limit int, where map[string]any) ([]QueryResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
// Build cache key from query + filters + limit
cacheKey := c.buildResultCacheKey(query, limit, where)
// Generate query embedding
queryEmb, err := c.embedSvc.Embed(query)
// Check result cache first
if results, ok := c.getResultFromCache(cacheKey); ok {
return results, nil
}
// Generate query embedding OUTSIDE the lock for better concurrency
queryEmb, err := c.getOrComputeEmbedding(query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
@@ -169,9 +282,13 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
return nil, fmt.Errorf("serialize query embedding: %w", err)
}
// Now acquire read lock for the actual DB query
c.readMu.RLock()
defer c.readMu.RUnlock()
// Build query with filters
// vec0 supports WHERE clauses on metadata columns
args := []interface{}{queryBlob}
args := []any{queryBlob}
sqlQuery := `
SELECT
@@ -232,6 +349,9 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
return nil, fmt.Errorf("iterate rows: %w", err)
}
// Cache the results
c.cacheResults(cacheKey, results)
log.Debug().
Str("query", truncateString(query, 50)).
Int("results", len(results)).
@@ -245,11 +365,196 @@ func (c *Client) IsConnected() bool {
return c.db != nil
}
// Close is a no-op (db managed externally).
// Close stops the background cleanup goroutine (db managed externally).
func (c *Client) Close() error {
// Signal cleanup goroutine to stop
close(c.stopCleanup)
// Wait for cleanup to finish
c.cleanupWg.Wait()
return nil
}
// cacheCleanupLoop periodically removes expired cache entries.
func (c *Client) cacheCleanupLoop() {
defer c.cleanupWg.Done()
ticker := time.NewTicker(30 * time.Second) // Cleanup every 30 seconds
defer ticker.Stop()
for {
select {
case <-c.stopCleanup:
return
case <-ticker.C:
c.cleanupExpiredCaches()
}
}
}
// cleanupExpiredCaches removes expired entries from both caches.
func (c *Client) cleanupExpiredCaches() {
now := time.Now().UnixNano()
var embeddingExpired, resultExpired int64
// Cleanup embedding cache
c.queryCacheMu.Lock()
for key, entry := range c.queryCache {
if now-entry.timestamp > c.cacheTTLNano {
delete(c.queryCache, key)
embeddingExpired++
}
}
c.queryCacheMu.Unlock()
// Cleanup result cache
c.resultCacheMu.Lock()
for key, entry := range c.resultCache {
if now-entry.timestamp > c.resultCacheTTLNano {
delete(c.resultCache, key)
resultExpired++
}
}
c.resultCacheMu.Unlock()
// Update stats atomically
if embeddingExpired > 0 || resultExpired > 0 {
c.stats.embeddingEvictions.Add(embeddingExpired)
c.stats.resultEvictions.Add(resultExpired)
log.Debug().
Int64("embedding_expired", embeddingExpired).
Int64("result_expired", resultExpired).
Msg("Cache cleanup completed")
}
}
// BatchQueryResult holds results from a batch query operation.
type BatchQueryResult struct {
Error error
Query string
Results []QueryResult
}
// QueryBatch performs multiple vector searches concurrently.
// Returns results in the same order as input queries.
// Uses a worker pool to limit concurrent queries.
func (c *Client) QueryBatch(ctx context.Context, queries []string, limit int, where map[string]any) []BatchQueryResult {
if len(queries) == 0 {
return nil
}
// Limit concurrency to avoid overwhelming the database
maxConcurrent := min(4, len(queries))
results := make([]BatchQueryResult, len(queries))
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for i, query := range queries {
wg.Add(1)
go func(idx int, q string) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
results[idx] = BatchQueryResult{
Query: q,
Error: ctx.Err(),
}
return
}
// Execute query
queryResults, err := c.Query(ctx, q, limit, where)
results[idx] = BatchQueryResult{
Query: q,
Results: queryResults,
Error: err,
}
}(i, query)
}
wg.Wait()
return results
}
// QueryMultiField searches across multiple fields for a single query.
// Combines results from different field types and deduplicates by document ID.
func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, docType string, project string) ([]QueryResult, error) {
// Generate embedding once
queryEmb, err := c.getOrComputeEmbedding(query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
// Serialize query embedding
queryBlob, err := sqlite_vec.SerializeFloat32(queryEmb)
if err != nil {
return nil, fmt.Errorf("serialize query embedding: %w", err)
}
c.readMu.RLock()
defer c.readMu.RUnlock()
// Query with field type aggregation - get best match per document
sqlQuery := `
WITH ranked_results AS (
SELECT
doc_id,
distance,
sqlite_id,
doc_type,
field_type,
project,
scope,
ROW_NUMBER() OVER (PARTITION BY sqlite_id ORDER BY distance ASC) as rn
FROM vectors
WHERE embedding MATCH ?
AND doc_type = ?
AND (project = ? OR scope = 'global')
)
SELECT doc_id, distance, sqlite_id, doc_type, field_type, project, scope
FROM ranked_results
WHERE rn = 1
ORDER BY distance
LIMIT ?
`
rows, err := c.db.QueryContext(ctx, sqlQuery, queryBlob, docType, project, limit)
if err != nil {
return nil, fmt.Errorf("query vectors: %w", err)
}
defer rows.Close()
// Pre-allocate with limit to avoid repeated slice growth
results := make([]QueryResult, 0, limit)
for rows.Next() {
var r QueryResult
var sqliteID int64
var docTypeVal, fieldType, projectVal, scope sql.NullString
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docTypeVal, &fieldType, &projectVal, &scope); err != nil {
return nil, fmt.Errorf("scan row: %w", err)
}
r.Similarity = DistanceToSimilarity(r.Distance)
r.Metadata = map[string]any{
"sqlite_id": float64(sqliteID),
"doc_type": docTypeVal.String,
"field_type": fieldType.String,
"project": projectVal.String,
"scope": scope.String,
}
results = append(results, r)
}
return results, rows.Err()
}
// truncateString truncates a string to maxLen characters.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
@@ -260,8 +565,8 @@ func truncateString(s string, maxLen int) string {
// Count returns the total number of vectors in the store.
func (c *Client) Count(ctx context.Context) (int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.readMu.RLock()
defer c.readMu.RUnlock()
var count int64
err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM vectors").Scan(&count)
@@ -281,8 +586,8 @@ func (c *Client) ModelVersion() string {
// - The vectors table is empty
// - Any vectors have a different model_version than the current model
func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
c.mu.Lock()
defer c.mu.Unlock()
c.readMu.RLock()
defer c.readMu.RUnlock()
currentModel := c.embedSvc.Version()
@@ -329,8 +634,8 @@ type StaleVectorInfo struct {
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
// This enables granular rebuild - only re-embedding documents that need updating.
func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.readMu.RLock()
defer c.readMu.RUnlock()
currentModel := c.embedSvc.Version()
@@ -372,6 +677,134 @@ func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error)
return results, nil
}
// VectorHealthStats contains comprehensive health information about the vector store.
type VectorHealthStats struct {
CoverageByType map[string]int64 `json:"coverage_by_type"`
ModelVersions map[string]int64 `json:"model_versions"`
ProjectCounts map[string]int64 `json:"project_counts"`
CurrentModel string `json:"current_model"`
RebuildReason string `json:"rebuild_reason,omitempty"`
EmbeddingCache CacheStatsSnapshot `json:"embedding_cache"`
TotalVectors int64 `json:"total_vectors"`
StaleVectors int64 `json:"stale_vectors"`
NeedsRebuild bool `json:"needs_rebuild"`
}
// GetHealthStats returns comprehensive health statistics about the vector store.
func (c *Client) GetHealthStats(ctx context.Context) (*VectorHealthStats, error) {
c.readMu.RLock()
defer c.readMu.RUnlock()
stats := &VectorHealthStats{
CurrentModel: c.embedSvc.Version(),
CoverageByType: make(map[string]int64),
ModelVersions: make(map[string]int64),
ProjectCounts: make(map[string]int64),
EmbeddingCache: c.stats.Snapshot(),
}
// Get total count
err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM vectors").Scan(&stats.TotalVectors)
if err != nil {
return nil, fmt.Errorf("count total vectors: %w", err)
}
// Get stale count
err = c.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM vectors WHERE model_version != ? OR model_version IS NULL",
stats.CurrentModel,
).Scan(&stats.StaleVectors)
if err != nil {
return nil, fmt.Errorf("count stale vectors: %w", err)
}
// Check if rebuild needed
stats.NeedsRebuild, stats.RebuildReason = c.needsRebuildUnlocked(ctx, stats.CurrentModel)
// Get coverage by doc_type
rows, err := c.db.QueryContext(ctx, "SELECT doc_type, COUNT(*) FROM vectors GROUP BY doc_type")
if err != nil {
return nil, fmt.Errorf("query doc types: %w", err)
}
defer rows.Close()
for rows.Next() {
var docType sql.NullString
var count int64
if err := rows.Scan(&docType, &count); err != nil {
return nil, fmt.Errorf("scan doc type: %w", err)
}
if docType.Valid {
stats.CoverageByType[docType.String] = count
} else {
stats.CoverageByType["unknown"] = count
}
}
// Get model version distribution
rows2, err := c.db.QueryContext(ctx, "SELECT COALESCE(model_version, 'unknown'), COUNT(*) FROM vectors GROUP BY model_version")
if err != nil {
return nil, fmt.Errorf("query model versions: %w", err)
}
defer rows2.Close()
for rows2.Next() {
var version string
var count int64
if err := rows2.Scan(&version, &count); err != nil {
return nil, fmt.Errorf("scan model version: %w", err)
}
stats.ModelVersions[version] = count
}
// Get project counts (top 10)
rows3, err := c.db.QueryContext(ctx,
"SELECT COALESCE(project, 'global'), COUNT(*) FROM vectors GROUP BY project ORDER BY COUNT(*) DESC LIMIT 10")
if err != nil {
return nil, fmt.Errorf("query projects: %w", err)
}
defer rows3.Close()
for rows3.Next() {
var project string
var count int64
if err := rows3.Scan(&project, &count); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
stats.ProjectCounts[project] = count
}
return stats, nil
}
// needsRebuildUnlocked checks if rebuild is needed without acquiring lock (caller must hold lock).
func (c *Client) needsRebuildUnlocked(ctx context.Context, currentModel string) (bool, string) {
var totalCount int64
err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM vectors").Scan(&totalCount)
if err != nil {
return false, ""
}
if totalCount == 0 {
return true, "empty"
}
var staleCount int64
err = c.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM vectors WHERE model_version != ? OR model_version IS NULL",
currentModel,
).Scan(&staleCount)
if err != nil {
return false, ""
}
if staleCount > 0 {
return true, fmt.Sprintf("model_mismatch:%d", staleCount)
}
return false, ""
}
// DeleteVectorsByDocIDs removes vectors by their doc_ids.
// Used for granular rebuild - delete stale vectors before re-adding.
func (c *Client) DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) error {
@@ -379,12 +812,12 @@ func (c *Client) DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) err
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
c.writeMu.Lock()
defer c.writeMu.Unlock()
// Build placeholder string
placeholders := make([]string, len(docIDs))
args := make([]interface{}, len(docIDs))
args := make([]any, len(docIDs))
for i, id := range docIDs {
placeholders[i] = "?"
args[i] = id
@@ -402,3 +835,249 @@ func (c *Client) DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) err
log.Debug().Int("count", len(docIDs)).Msg("Deleted stale vectors by doc_id")
return nil
}
// DeleteByObservationID removes all vectors associated with an observation ID.
// Vectors are stored with doc_ids that include the observation ID, e.g., "obs_123_narrative".
func (c *Client) DeleteByObservationID(ctx context.Context, obsID int64) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
// Vectors have doc_ids like "obs_123_narrative", "obs_123_facts_0", etc.
pattern := fmt.Sprintf("obs_%d_%%", obsID)
_, err := c.db.ExecContext(ctx, "DELETE FROM vectors WHERE doc_id LIKE ?", pattern)
if err != nil {
return fmt.Errorf("delete vectors for observation %d: %w", obsID, err)
}
log.Debug().Int64("observation_id", obsID).Msg("Deleted vectors for observation")
return nil
}
// getOrComputeEmbedding returns a cached embedding or computes a new one.
// Uses singleflight to prevent duplicate concurrent computations for the same query.
func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
now := time.Now().UnixNano()
// Check cache first (read lock)
c.queryCacheMu.RLock()
if entry, ok := c.queryCache[query]; ok {
if now-entry.timestamp < c.cacheTTLNano {
c.queryCacheMu.RUnlock()
c.stats.embeddingHits.Add(1)
return entry.embedding, nil
}
}
c.queryCacheMu.RUnlock()
// Cache miss - use singleflight to deduplicate concurrent embedding requests
result, err, _ := c.embeddingGroup.Do(query, func() (any, error) {
// Double-check cache inside singleflight (another goroutine may have just cached it)
c.queryCacheMu.RLock()
if entry, ok := c.queryCache[query]; ok {
if time.Now().UnixNano()-entry.timestamp < c.cacheTTLNano {
c.queryCacheMu.RUnlock()
return entry.embedding, nil
}
}
c.queryCacheMu.RUnlock()
// Record cache miss
c.stats.embeddingMisses.Add(1)
// Compute embedding
emb, err := c.embedSvc.Embed(query)
if err != nil {
return nil, err
}
// Store in cache (write lock)
c.queryCacheMu.Lock()
nowCache := time.Now().UnixNano()
// Evict old entries if cache is full or near capacity (80% threshold)
evictionThreshold := (c.cacheMaxSize * 8) / 10
if len(c.queryCache) >= evictionThreshold {
// Phase 1: Remove ALL expired entries first (not just 10%)
evicted := int64(0)
for k, v := range c.queryCache {
if nowCache-v.timestamp > c.cacheTTLNano {
delete(c.queryCache, k)
evicted++
}
}
// Phase 2: If still at capacity, evict 10% using random iteration (O(n) instead of O(n log n))
// Go map iteration order is randomized, providing good cache behavior without sorting
if len(c.queryCache) >= c.cacheMaxSize {
evictCount := max(c.cacheMaxSize/10, 1)
for k := range c.queryCache {
delete(c.queryCache, k)
evicted++
evictCount--
if evictCount <= 0 {
break
}
}
}
if evicted > 0 {
c.stats.embeddingEvictions.Add(evicted)
}
}
c.queryCache[query] = embeddingCacheEntry{
embedding: emb,
timestamp: nowCache,
}
c.queryCacheMu.Unlock()
return emb, nil
})
if err != nil {
return nil, err
}
return result.([]float32), nil
}
// ClearCache clears the embedding cache.
func (c *Client) ClearCache() {
c.queryCacheMu.Lock()
c.queryCache = make(map[string]embeddingCacheEntry)
c.queryCacheMu.Unlock()
}
// GetCacheStats returns comprehensive cache statistics.
func (c *Client) GetCacheStats() CacheStatsSnapshot {
return c.stats.Snapshot()
}
// CacheStats returns basic cache size info for backward compatibility.
// Deprecated: Use GetCacheStats() for comprehensive statistics.
func (c *Client) CacheStats() (size int, maxSize int) {
c.queryCacheMu.RLock()
size = len(c.queryCache)
c.queryCacheMu.RUnlock()
return size, c.cacheMaxSize
}
// EmbeddingCacheSize returns the current embedding cache size.
func (c *Client) EmbeddingCacheSize() int {
c.queryCacheMu.RLock()
defer c.queryCacheMu.RUnlock()
return len(c.queryCache)
}
// ResultCacheSize returns the current result cache size.
func (c *Client) ResultCacheSize() int {
c.resultCacheMu.RLock()
defer c.resultCacheMu.RUnlock()
return len(c.resultCache)
}
// buildResultCacheKey creates a unique key for caching query results.
// Uses strings.Builder to avoid intermediate allocations.
func (c *Client) buildResultCacheKey(query string, limit int, where map[string]any) string {
// Pre-allocate with typical key size to avoid reallocation
var b strings.Builder
b.Grow(len(query) + 32) // query + typical prefix/suffix overhead
b.WriteString("q:")
b.WriteString(query)
b.WriteString(":l:")
b.WriteString(strconv.Itoa(limit))
if docType, ok := where["doc_type"].(string); ok {
b.WriteString(":dt:")
b.WriteString(docType)
}
if project, ok := where["project"].(string); ok {
b.WriteString(":p:")
b.WriteString(project)
}
return b.String()
}
// getResultFromCache retrieves cached results if available and not expired.
func (c *Client) getResultFromCache(cacheKey string) ([]QueryResult, bool) {
now := time.Now().UnixNano()
c.resultCacheMu.RLock()
entry, ok := c.resultCache[cacheKey]
c.resultCacheMu.RUnlock()
if !ok {
c.stats.resultMisses.Add(1)
return nil, false
}
// Check if entry is expired
if now-entry.timestamp > c.resultCacheTTLNano {
c.stats.resultMisses.Add(1)
return nil, false
}
c.stats.resultHits.Add(1)
// Return a copy to prevent mutation
results := make([]QueryResult, len(entry.results))
copy(results, entry.results)
return results, true
}
// cacheResults stores query results in the cache.
func (c *Client) cacheResults(cacheKey string, results []QueryResult) {
now := time.Now().UnixNano()
c.resultCacheMu.Lock()
defer c.resultCacheMu.Unlock()
// Evict old entries if cache is full
if len(c.resultCache) >= c.resultCacheMaxSize {
// Two-phase eviction: (1) TTL-expired entries, (2) random if still over capacity
evicted := 0
targetSize := c.resultCacheMaxSize * 8 / 10 // Target 80% capacity
// Phase 1: Remove all TTL-expired entries
for k, v := range c.resultCache {
if now-v.timestamp > c.resultCacheTTLNano {
delete(c.resultCache, k)
evicted++
}
}
// Phase 2: If still over target, remove random entries until at target
if len(c.resultCache) >= targetSize {
evictCount := len(c.resultCache) - targetSize + 1
for k := range c.resultCache {
delete(c.resultCache, k)
evicted++
evictCount--
if evictCount <= 0 {
break
}
}
}
if evicted > 0 {
c.stats.resultEvictions.Add(int64(evicted))
}
}
// Make a copy of results to store
resultsCopy := make([]QueryResult, len(results))
copy(resultsCopy, results)
c.resultCache[cacheKey] = resultCacheEntry{
results: resultsCopy,
timestamp: now,
queryHash: cacheKey,
}
}
// InvalidateResultCache clears the result cache.
// Should be called after write operations that modify vectors.
func (c *Client) InvalidateResultCache() {
c.resultCacheMu.Lock()
c.resultCache = make(map[string]resultCacheEntry)
c.resultCacheMu.Unlock()
}
File diff suppressed because it is too large Load Diff
+184
View File
@@ -338,3 +338,187 @@ func (s *Sync) DeletePatterns(ctx context.Context, patternIDs []int64) error {
return nil
}
// BatchSyncConfig configures batch synchronization behavior.
type BatchSyncConfig struct {
BatchSize int // Number of items per batch (default: 50)
ProgressLogFreq int // Log progress every N items (default: 100)
}
// DefaultBatchSyncConfig returns sensible defaults for batch sync.
func DefaultBatchSyncConfig() BatchSyncConfig {
return BatchSyncConfig{
BatchSize: 50,
ProgressLogFreq: 100,
}
}
// BatchSyncObservations syncs multiple observations efficiently in batches.
// This reduces memory pressure during large rebuilds by processing in chunks.
func (s *Sync) BatchSyncObservations(ctx context.Context, observations []*models.Observation, cfg BatchSyncConfig) (synced int, errors int) {
if len(observations) == 0 {
return 0, 0
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = 50
}
if cfg.ProgressLogFreq <= 0 {
cfg.ProgressLogFreq = 100
}
for i := 0; i < len(observations); i += cfg.BatchSize {
// Check context cancellation
select {
case <-ctx.Done():
log.Warn().Int("synced", synced).Int("remaining", len(observations)-i).Msg("Batch sync cancelled")
return synced, errors
default:
}
end := min(i+cfg.BatchSize, len(observations))
batch := observations[i:end]
var docs []Document
// Collect all documents for this batch
for _, obs := range batch {
docs = append(docs, s.formatObservationDocs(obs)...)
}
// Add all documents in one call
if len(docs) > 0 {
if err := s.client.AddDocuments(ctx, docs); err != nil {
log.Warn().Err(err).Int("batchStart", i).Int("batchSize", len(batch)).Msg("Failed to sync observation batch")
errors += len(batch)
continue
}
}
synced += len(batch)
// Log progress periodically
if synced%cfg.ProgressLogFreq == 0 || synced == len(observations) {
log.Debug().Int("synced", synced).Int("total", len(observations)).Msg("Observation batch sync progress")
}
}
return synced, errors
}
// BatchSyncSummaries syncs multiple summaries efficiently in batches.
func (s *Sync) BatchSyncSummaries(ctx context.Context, summaries []*models.SessionSummary, cfg BatchSyncConfig) (synced int, errors int) {
if len(summaries) == 0 {
return 0, 0
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = 50
}
if cfg.ProgressLogFreq <= 0 {
cfg.ProgressLogFreq = 100
}
for i := 0; i < len(summaries); i += cfg.BatchSize {
// Check context cancellation
select {
case <-ctx.Done():
log.Warn().Int("synced", synced).Int("remaining", len(summaries)-i).Msg("Batch sync cancelled")
return synced, errors
default:
}
end := min(i+cfg.BatchSize, len(summaries))
batch := summaries[i:end]
var docs []Document
// Collect all documents for this batch
for _, summary := range batch {
docs = append(docs, s.formatSummaryDocs(summary)...)
}
// Add all documents in one call
if len(docs) > 0 {
if err := s.client.AddDocuments(ctx, docs); err != nil {
log.Warn().Err(err).Int("batchStart", i).Int("batchSize", len(batch)).Msg("Failed to sync summary batch")
errors += len(batch)
continue
}
}
synced += len(batch)
// Log progress periodically
if synced%cfg.ProgressLogFreq == 0 || synced == len(summaries) {
log.Debug().Int("synced", synced).Int("total", len(summaries)).Msg("Summary batch sync progress")
}
}
return synced, errors
}
// BatchSyncPrompts syncs multiple user prompts efficiently in batches.
func (s *Sync) BatchSyncPrompts(ctx context.Context, prompts []*models.UserPromptWithSession, cfg BatchSyncConfig) (synced int, errors int) {
if len(prompts) == 0 {
return 0, 0
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = 50
}
if cfg.ProgressLogFreq <= 0 {
cfg.ProgressLogFreq = 100
}
for i := 0; i < len(prompts); i += cfg.BatchSize {
// Check context cancellation
select {
case <-ctx.Done():
log.Warn().Int("synced", synced).Int("remaining", len(prompts)-i).Msg("Batch sync cancelled")
return synced, errors
default:
}
end := min(i+cfg.BatchSize, len(prompts))
batch := prompts[i:end]
docs := make([]Document, 0, len(batch))
// Collect all documents for this batch
for _, prompt := range batch {
docs = append(docs, Document{
ID: fmt.Sprintf("prompt_%d", prompt.ID),
Content: prompt.PromptText,
Metadata: map[string]any{
"sqlite_id": prompt.ID,
"doc_type": "user_prompt",
"sdk_session_id": prompt.SDKSessionID,
"project": prompt.Project,
"scope": "",
"created_at_epoch": prompt.CreatedAtEpoch,
"prompt_number": prompt.PromptNumber,
"field_type": "prompt",
},
})
}
// Add all documents in one call
if len(docs) > 0 {
if err := s.client.AddDocuments(ctx, docs); err != nil {
log.Warn().Err(err).Int("batchStart", i).Int("batchSize", len(batch)).Msg("Failed to sync prompt batch")
errors += len(batch)
continue
}
}
synced += len(batch)
// Log progress periodically
if synced%cfg.ProgressLogFreq == 0 || synced == len(prompts) {
log.Debug().Int("synced", synced).Int("total", len(prompts)).Msg("Prompt batch sync progress")
}
}
return synced, errors
}
+173 -1284
View File
File diff suppressed because it is too large Load Diff
+677
View File
@@ -0,0 +1,677 @@
// Package worker provides context and search-related HTTP handlers.
package worker
import (
"context"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/reranking"
"github.com/lukaszraczylo/claude-mnemonic/internal/search/expansion"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/sdk"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// handleSearchByPrompt searches observations relevant to a user prompt.
// IMPORTANT: This is on the critical startup path - must be fast!
// No synchronous verification - just filter by staleness and return.
func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
cwd := r.URL.Query().Get("cwd")
if project == "" || query == "" {
http.Error(w, "project and query required", http.StatusBadRequest)
return
}
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
limit := gorm.ParseLimitParamWithMax(r, DefaultSearchLimit, 200)
var observations []*models.Observation
var err error
var usedVector bool
similarityScores := make(map[int64]float64) // Track similarity per observation
// Get threshold settings from config
threshold := s.config.ContextRelevanceThreshold
maxResults := s.config.ContextMaxPromptResults
// Generate expanded queries if query expander is available
// Use timeout context to prevent query expansion from blocking
var expandedQueries []expansion.ExpandedQuery
var detectedIntent string
if s.queryExpander != nil {
expandCtx, expandCancel := context.WithTimeout(r.Context(), 5*time.Second)
cfg := expansion.DefaultConfig()
cfg.EnableVocabularyExpansion = false // Vocabulary expansion is optional
expandedQueries = s.queryExpander.Expand(expandCtx, query, cfg)
expandCancel() // Cancel immediately after use (defer not needed - no panic possible between creation and here)
if len(expandedQueries) > 0 {
detectedIntent = string(expandedQueries[0].Intent)
}
}
if len(expandedQueries) == 0 {
// Fallback to just the original query
expandedQueries = []expansion.ExpandedQuery{
{Query: query, Weight: 1.0, Source: "original"},
}
}
// Try vector search first if available
var vectorSearchFailed bool
if s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
// Search with each expanded query and merge results
// Pre-allocate with estimated capacity to avoid repeated reallocation
estimatedCapacity := len(expandedQueries) * limit * 2
allVectorResults := make([]sqlitevec.QueryResult, 0, estimatedCapacity)
queryWeights := make(map[string]float64, len(expandedQueries))
var vectorErrors int
for _, eq := range expandedQueries {
vectorResults, vecErr := s.vectorClient.Query(r.Context(), eq.Query, limit*2, where)
if vecErr != nil {
vectorErrors++
log.Debug().Err(vecErr).Str("query", eq.Query).Msg("Vector query failed")
} else if len(vectorResults) > 0 {
// Apply weight to similarity scores before merging
for i := range vectorResults {
vectorResults[i].Similarity *= eq.Weight
}
allVectorResults = append(allVectorResults, vectorResults...)
queryWeights[eq.Query] = eq.Weight
}
}
// Track if vector search had issues
if vectorErrors > 0 && vectorErrors == len(expandedQueries) {
vectorSearchFailed = true
log.Warn().Int("errors", vectorErrors).Str("project", project).Msg("All vector queries failed, falling back to FTS")
}
if len(allVectorResults) > 0 {
// Filter by relevance threshold before extracting IDs
// Use a slightly lower threshold for expanded queries
effectiveThreshold := threshold * 0.9 // Allow slightly lower scores for expanded queries
filteredResults := sqlitevec.FilterByThreshold(allVectorResults, effectiveThreshold, 0)
// Build similarity map for filtered results (keeping highest weighted score per observation)
for _, vr := range filteredResults {
if sqliteID, ok := vr.Metadata["sqlite_id"].(float64); ok {
id := int64(sqliteID)
// Keep the highest score for each observation
if existing, exists := similarityScores[id]; !exists || vr.Similarity > existing {
similarityScores[id] = vr.Similarity
}
}
}
// Extract observation IDs with project/scope filtering using shared helper
obsIDs := sqlitevec.ExtractObservationIDs(filteredResults, project)
if len(obsIDs) > 0 {
// Fetch full observations from SQLite
observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
}
}
}
// Fall back to FTS if vector search not available, failed, or returned no results
if !usedVector || len(observations) == 0 {
if vectorSearchFailed {
log.Info().Str("project", project).Msg("Using FTS fallback due to vector search failure")
}
observations, err = s.observationStore.SearchObservationsFTS(r.Context(), query, project, limit)
if err != nil {
// FTS might fail if query has special chars, try without
log.Warn().Err(err).Str("query", query).Msg("FTS search failed, falling back to recent")
observations, err = s.observationStore.GetRecentObservations(r.Context(), project, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
// Fast staleness filter - NO verification (that's too slow for interactive use)
// Just check mtimes and exclude obviously stale observations
var staleCount int
freshObservations := make([]*models.Observation, 0, len(observations))
for _, obs := range observations {
if len(obs.FileMtimes) > 0 && cwd != "" {
var paths []string
for path := range obs.FileMtimes {
paths = append(paths, path)
}
currentMtimes := sdk.GetFileMtimes(paths, cwd)
if obs.CheckStaleness(currentMtimes) {
// Stale - exclude but don't verify (too slow)
// Queue for background verification instead
staleCount++
s.queueStaleVerification(obs.ID, cwd)
continue
}
}
freshObservations = append(freshObservations, obs)
}
// Apply cross-encoder reranking if available
var reranked bool
if s.reranker != nil && len(freshObservations) > 0 && usedVector {
// Build candidates from observations with their bi-encoder scores
candidates := make([]reranking.Candidate, len(freshObservations))
for i, obs := range freshObservations {
// Use strings.Builder for efficient concatenation
var content string
if obs.Narrative.Valid && obs.Narrative.String != "" {
var sb strings.Builder
sb.Grow(len(obs.Title.String) + 1 + len(obs.Narrative.String))
sb.WriteString(obs.Title.String)
sb.WriteByte(' ')
sb.WriteString(obs.Narrative.String)
content = sb.String()
} else {
content = obs.Title.String
}
candidates[i] = reranking.Candidate{
ID: strconv.FormatInt(obs.ID, 10), // Faster than fmt.Sprintf
Content: content,
Score: similarityScores[obs.ID],
Metadata: map[string]any{"obs_idx": i},
}
}
// Rerank using cross-encoder - use pure mode or combined scores
var rerankResults []reranking.RerankResult
var rerankErr error
if s.config.RerankingPureMode {
rerankResults, rerankErr = s.reranker.RerankByScore(query, candidates, s.config.RerankingResults)
} else {
rerankResults, rerankErr = s.reranker.Rerank(query, candidates, s.config.RerankingResults)
}
if rerankErr != nil {
log.Warn().Err(rerankErr).Msg("Cross-encoder reranking failed, using original order")
} else if len(rerankResults) > 0 {
// Update similarity scores with reranked scores
for _, rr := range rerankResults {
if id, err := strconv.ParseInt(rr.ID, 10, 64); err == nil {
similarityScores[id] = rr.CombinedScore
}
}
// Reorder observations based on rerank results
reorderedObs := make([]*models.Observation, 0, len(rerankResults))
obsMap := make(map[int64]*models.Observation)
for _, obs := range freshObservations {
obsMap[obs.ID] = obs
}
for _, rr := range rerankResults {
if id, err := strconv.ParseInt(rr.ID, 10, 64); err == nil {
if obs, ok := obsMap[id]; ok {
reorderedObs = append(reorderedObs, obs)
}
}
}
freshObservations = reorderedObs
reranked = true
log.Debug().
Int("candidates", len(candidates)).
Int("returned", len(rerankResults)).
Msg("Cross-encoder reranking complete")
}
}
// Cluster similar observations to remove duplicates
clusteredObservations := clusterObservations(freshObservations, 0.4)
duplicatesRemoved := len(freshObservations) - len(clusteredObservations)
// Sort by similarity score (highest first) if we have scores and didn't rerank
if len(similarityScores) > 0 && len(clusteredObservations) > 0 && !reranked {
sort.Slice(clusteredObservations, func(i, j int) bool {
scoreI := similarityScores[clusteredObservations[i].ID]
scoreJ := similarityScores[clusteredObservations[j].ID]
return scoreI > scoreJ
})
}
// Apply max results cap if configured
if maxResults > 0 && len(clusteredObservations) > maxResults {
clusteredObservations = clusteredObservations[:maxResults]
}
// Record retrieval stats with staleness metrics
s.recordRetrievalStatsExtended(project, int64(len(clusteredObservations)), 0, 0,
int64(staleCount), int64(len(freshObservations)), int64(duplicatesRemoved), true)
// Increment retrieval counts for scoring (async, non-blocking)
if len(clusteredObservations) > 0 {
ids := make([]int64, len(clusteredObservations))
for i, obs := range clusteredObservations {
ids[i] = obs.ID
}
s.incrementRetrievalCounts(ids)
}
log.Info().
Str("project", project).
Str("query", query).
Str("intent", detectedIntent).
Int("expansions", len(expandedQueries)).
Int("found", len(clusteredObservations)).
Int("stale_excluded", staleCount).
Float64("threshold", threshold).
Msg("Prompt-based observation search")
// Build response with similarity scores
obsWithScores := make([]map[string]any, len(clusteredObservations))
for i, obs := range clusteredObservations {
obsMap := obs.ToMap()
if score, ok := similarityScores[obs.ID]; ok {
obsMap["similarity"] = score
}
obsWithScores[i] = obsMap
}
// Build expansion info for response
expansionInfo := make([]map[string]any, len(expandedQueries))
for i, eq := range expandedQueries {
expansionInfo[i] = map[string]any{
"query": eq.Query,
"weight": eq.Weight,
"source": eq.Source,
}
}
// Track this search for analytics
s.trackSearchQuery(query, project, "observations", len(clusteredObservations), usedVector)
writeJSON(w, map[string]any{
"project": project,
"query": query,
"intent": detectedIntent,
"expansions": expansionInfo,
"observations": obsWithScores,
"threshold": threshold,
"max_results": maxResults,
})
}
// handleFileContext returns observations relevant to specific files being worked on.
// Uses vector similarity search to find observations that mention or relate to the given files.
func (s *Service) handleFileContext(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "project required", http.StatusBadRequest)
return
}
filesParam := r.URL.Query().Get("files")
if filesParam == "" {
http.Error(w, "files required", http.StatusBadRequest)
return
}
// Parse comma-separated file paths
files := strings.Split(filesParam, ",")
if len(files) == 0 {
http.Error(w, "at least one file required", http.StatusBadRequest)
return
}
// Limit to reasonable number of files
maxFiles := 20
if len(files) > maxFiles {
files = files[:maxFiles]
}
// Get limit parameter (default 10 per file)
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 50 {
limit = parsed
}
}
// Search for observations related to each file in parallel
ctx := r.Context()
// Check if vector search is available
if s.vectorClient == nil || !s.vectorClient.IsConnected() {
writeJSON(w, map[string]any{
"files": files,
"results": map[string]any{},
"count": 0,
"error": "vector search not available",
})
return
}
// Prepare for parallel execution
type fileResult struct {
file string
results []map[string]any
obsIDs []int64 // Track observation IDs for deduplication
}
resultsChan := make(chan fileResult, len(files))
sem := make(chan struct{}, 5) // Limit concurrency to 5 parallel searches
var wg sync.WaitGroup
for _, file := range files {
file = strings.TrimSpace(file)
if file == "" {
continue
}
wg.Add(1)
go func(file string) {
defer wg.Done()
sem <- struct{}{} // Acquire semaphore
defer func() { <-sem }() // Release semaphore
// Build search query from file path
query := buildFileQuery(file)
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
vectorResults, vecErr := s.vectorClient.Query(ctx, query, limit*2, where)
if vecErr != nil {
log.Warn().Err(vecErr).Str("file", file).Msg("Vector search failed for file context")
return
}
// Extract observation IDs from vector results
obsIDs := sqlitevec.ExtractObservationIDs(vectorResults, project)
if len(obsIDs) == 0 {
return
}
// Fetch observations
observations, err := s.observationStore.GetObservationsByIDs(ctx, obsIDs, "score_desc", limit*2)
if err != nil {
log.Warn().Err(err).Str("file", file).Msg("Failed to fetch observations for file context")
return
}
// Pre-build score map from vector results (O(n) instead of O(n²))
scoreMap := make(map[int64]float64, len(vectorResults))
var avgScore float64
for _, vr := range vectorResults {
avgScore += vr.Similarity
// Parse observation ID from vector result ID (format: obs_{id}_{field})
// Use index-based parsing to avoid slice allocation from strings.Split
if len(vr.ID) > 4 && vr.ID[:4] == "obs_" {
rest := vr.ID[4:] // Skip "obs_"
underscoreIdx := strings.IndexByte(rest, '_')
var idStr string
if underscoreIdx >= 0 {
idStr = rest[:underscoreIdx]
} else {
idStr = rest
}
if id, parseErr := strconv.ParseInt(idStr, 10, 64); parseErr == nil {
// Keep highest score for each observation
if existing, exists := scoreMap[id]; !exists || vr.Similarity > existing {
scoreMap[id] = vr.Similarity
}
}
}
}
if len(vectorResults) > 0 {
avgScore /= float64(len(vectorResults))
}
fileResults := make([]map[string]any, 0, limit)
var usedIDs []int64
for _, obs := range observations {
// Check project scope
if obs.Scope == "project" && obs.Project != project {
continue
}
// O(1) score lookup instead of O(n) nested loop
score, found := scoreMap[obs.ID]
if !found {
// Use average score as fallback
score = avgScore
}
// Only include if score is above threshold
if score < 0.3 {
continue
}
fileResults = append(fileResults, map[string]any{
"id": obs.ID,
"title": obs.Title.String,
"type": obs.Type,
"narrative": obs.Narrative.String,
"facts": obs.Facts,
"score": score,
})
usedIDs = append(usedIDs, obs.ID)
if len(fileResults) >= limit {
break
}
}
if len(fileResults) > 0 {
resultsChan <- fileResult{file: file, results: fileResults, obsIDs: usedIDs}
}
}(file)
}
// Close channel when all goroutines complete
go func() {
wg.Wait()
close(resultsChan)
}()
// Collect results and deduplicate
allResults := make(map[string]any)
seenObservationIDs := make(map[int64]bool)
for res := range resultsChan {
// Filter out duplicates that were already seen in other files
dedupedResults := make([]map[string]any, 0, len(res.results))
for i, r := range res.results {
obsID := res.obsIDs[i]
if !seenObservationIDs[obsID] {
seenObservationIDs[obsID] = true
dedupedResults = append(dedupedResults, r)
}
}
if len(dedupedResults) > 0 {
allResults[res.file] = dedupedResults
}
}
writeJSON(w, map[string]any{
"files": files,
"results": allResults,
"count": len(allResults),
})
}
// buildFileQuery extracts meaningful search terms from a file path.
func buildFileQuery(filePath string) string {
// Remove common prefixes and extensions
path := strings.TrimPrefix(filePath, "/")
// Extract the filename and directory
parts := strings.Split(path, "/")
meaningful := make([]string, 0, len(parts))
for _, part := range parts {
// Skip common directory names that aren't meaningful
switch strings.ToLower(part) {
case "src", "lib", "internal", "pkg", "cmd", "api", "app", "test", "tests", "spec", "specs":
continue
default:
// Remove file extension
if idx := strings.LastIndex(part, "."); idx > 0 {
part = part[:idx]
}
// Convert camelCase/PascalCase to spaces
part = splitCamelCase(part)
// Convert snake_case to spaces
part = strings.ReplaceAll(part, "_", " ")
// Convert kebab-case to spaces
part = strings.ReplaceAll(part, "-", " ")
meaningful = append(meaningful, part)
}
}
return strings.Join(meaningful, " ")
}
// splitCamelCase splits camelCase or PascalCase into separate words.
func splitCamelCase(s string) string {
var result strings.Builder
for i, r := range s {
if i > 0 && r >= 'A' && r <= 'Z' {
result.WriteRune(' ')
}
result.WriteRune(r)
}
return result.String()
}
// handleContextInject returns context for injection at session start.
// IMPORTANT: This is on the critical startup path - must be fast!
// No synchronous verification - just filter by staleness and return.
func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "project required", http.StatusBadRequest)
return
}
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cwd := r.URL.Query().Get("cwd")
if cwd == "" {
cwd = "/"
}
// Limit observations for fast startup (configurable, default 100)
limit := s.config.ContextObservations
if limit <= 0 {
limit = DefaultContextLimit
}
// Full count determines how many observations get full detail (configurable, default 25)
fullCount := s.config.ContextFullCount
if fullCount <= 0 {
fullCount = 25
}
// Get recent observations
observations, err := s.observationStore.GetRecentObservations(r.Context(), project, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Fast staleness filter - NO verification (that's too slow for startup)
var staleCount int
freshObservations := make([]*models.Observation, 0, len(observations))
for _, obs := range observations {
if len(obs.FileMtimes) > 0 {
var paths []string
for path := range obs.FileMtimes {
paths = append(paths, path)
}
currentMtimes := sdk.GetFileMtimes(paths, cwd)
if obs.CheckStaleness(currentMtimes) {
// Stale - exclude but don't verify (too slow)
// Queue for background verification instead
staleCount++
s.queueStaleVerification(obs.ID, cwd)
continue
}
}
freshObservations = append(freshObservations, obs)
}
// Cluster similar observations to remove duplicates
clusteredObservations := clusterObservations(freshObservations, 0.4)
duplicatesRemoved := len(freshObservations) - len(clusteredObservations)
// Record retrieval stats with staleness metrics
s.recordRetrievalStatsExtended(project, int64(len(clusteredObservations)), 0, 0,
int64(staleCount), int64(len(freshObservations)), int64(duplicatesRemoved), false)
// Increment retrieval counts for scoring (async, non-blocking)
if len(clusteredObservations) > 0 {
ids := make([]int64, len(clusteredObservations))
for i, obs := range clusteredObservations {
ids[i] = obs.ID
}
s.incrementRetrievalCounts(ids)
}
log.Info().
Str("project", project).
Int("total", len(observations)).
Int("fresh", len(freshObservations)).
Int("clustered", len(clusteredObservations)).
Int("duplicates", duplicatesRemoved).
Int("stale_excluded", staleCount).
Msg("Context injection with clustering")
writeJSON(w, map[string]any{
"project": project,
"observations": clusteredObservations,
"full_count": fullCount,
"stale_excluded": staleCount,
"duplicates_removed": duplicatesRemoved,
})
}
// handleContextCount returns the count of observations for a project.
func (s *Service) handleContextCount(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "project required", http.StatusBadRequest)
return
}
count, err := s.getCachedObservationCount(r.Context(), project)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{
"project": project,
"count": count,
})
}
+710
View File
@@ -0,0 +1,710 @@
// Package worker provides data retrieval HTTP handlers.
package worker
import (
"encoding/json"
"net/http"
"runtime"
"sort"
"strings"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// handleGetObservations returns recent observations.
// Supports optional query parameter for semantic search via sqlite-vec.
// Supports pagination via limit and offset query parameters.
func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) {
pagination := gorm.ParsePaginationParams(r, DefaultObservationsLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var observations []*models.Observation
var total int64
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, pagination.Limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
obsIDs := sqlitevec.ExtractObservationIDs(vectorResults, project)
if len(obsIDs) > 0 {
observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", pagination.Limit)
if err == nil {
usedVector = true
total = int64(len(observations)) // Vector search doesn't have total, use returned count
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
// Strict project filtering for dashboard - only observations from this project
observations, total, err = s.observationStore.GetObservationsByProjectStrictPaginated(r.Context(), project, pagination.Limit, pagination.Offset)
} else {
// All projects
observations, total, err = s.observationStore.GetAllRecentObservationsPaginated(r.Context(), pagination.Limit, pagination.Offset)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if observations == nil {
observations = []*models.Observation{}
}
// Track search if query was provided
if query != "" {
s.trackSearchQuery(query, project, "observations", len(observations), usedVector)
}
// Return paginated response
writeJSON(w, map[string]any{
"observations": observations,
"total": total,
"limit": pagination.Limit,
"offset": pagination.Offset,
"hasMore": int64(pagination.Offset)+int64(len(observations)) < total,
})
}
// handleGetSummaries returns recent summaries.
// Supports optional query parameter for semantic search via sqlite-vec.
func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
limit := gorm.ParseLimitParam(r, DefaultSummariesLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var summaries []*models.SessionSummary
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeSessionSummary, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
summaryIDs := sqlitevec.ExtractSummaryIDs(vectorResults, project)
if len(summaryIDs) > 0 {
summaries, err = s.summaryStore.GetSummariesByIDs(r.Context(), summaryIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
summaries, err = s.summaryStore.GetRecentSummaries(r.Context(), project, limit)
} else {
summaries, err = s.summaryStore.GetAllRecentSummaries(r.Context(), limit)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if summaries == nil {
summaries = []*models.SessionSummary{}
}
writeJSON(w, summaries)
}
// handleGetPrompts returns recent user prompts.
// Supports optional query parameter for semantic search via sqlite-vec.
func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
limit := gorm.ParseLimitParam(r, DefaultPromptsLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var prompts []*models.UserPromptWithSession
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeUserPrompt, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
promptIDs := sqlitevec.ExtractPromptIDs(vectorResults, project)
if len(promptIDs) > 0 {
prompts, err = s.promptStore.GetPromptsByIDs(r.Context(), promptIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
prompts, err = s.promptStore.GetRecentUserPromptsByProject(r.Context(), project, limit)
} else {
prompts, err = s.promptStore.GetAllRecentUserPrompts(r.Context(), limit)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if prompts == nil {
prompts = []*models.UserPromptWithSession{}
}
writeJSON(w, prompts)
}
// handleGetProjects returns all projects.
// Response is cacheable for 5 minutes since project list changes infrequently.
func (s *Service) handleGetProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.sessionStore.GetAllProjects(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Cache for 5 minutes - project list changes infrequently
w.Header().Set("Cache-Control", "public, max-age=300")
writeJSON(w, projects)
}
// handleGetTypes returns the canonical list of observation and concept types.
// This provides a single source of truth for both backend and frontend.
// Response is cacheable as these values never change at runtime.
func (s *Service) handleGetTypes(w http.ResponseWriter, r *http.Request) {
// Cache for 24 hours - these values are compile-time constants
w.Header().Set("Cache-Control", "public, max-age=86400")
writeJSON(w, map[string]any{
"observation_types": ObservationTypes,
"concept_types": ConceptTypes,
})
}
// handleGetModels returns available embedding models.
// Response is cacheable as model list doesn't change without restart.
func (s *Service) handleGetModels(w http.ResponseWriter, _ *http.Request) {
// Cache for 1 hour - model list is static during runtime
w.Header().Set("Cache-Control", "public, max-age=3600")
models := embedding.ListModels()
defaultModel := embedding.GetDefaultModel()
writeJSON(w, map[string]any{
"models": models,
"default": defaultModel,
"current": s.embedSvc.Version(),
})
}
// handleGetStats returns worker statistics.
func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
retrievalStats := s.GetRetrievalStats(project)
sessionsToday, _ := s.sessionStore.GetSessionsToday(r.Context())
response := map[string]any{
"uptime": time.Since(s.startTime).String(),
"uptimeSeconds": time.Since(s.startTime).Seconds(),
"activeSessions": s.sessionManager.GetActiveSessionCount(),
"queueDepth": s.sessionManager.GetTotalQueueDepth(),
"isProcessing": s.sessionManager.IsAnySessionProcessing(),
"connectedClients": s.sseBroadcaster.ClientCount(),
"sessionsToday": sessionsToday,
"retrieval": retrievalStats,
"ready": s.ready.Load(),
}
// Add memory stats
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
response["memory"] = map[string]any{
"alloc_mb": float64(memStats.Alloc) / 1024 / 1024,
"total_alloc_mb": float64(memStats.TotalAlloc) / 1024 / 1024,
"sys_mb": float64(memStats.Sys) / 1024 / 1024,
"heap_alloc_mb": float64(memStats.HeapAlloc) / 1024 / 1024,
"heap_inuse_mb": float64(memStats.HeapInuse) / 1024 / 1024,
"heap_objects": memStats.HeapObjects,
"goroutines": runtime.NumGoroutine(),
"gc_cycles": memStats.NumGC,
"gc_pause_total_ms": float64(memStats.PauseTotalNs) / 1e6,
}
// Add database health if available
if s.store != nil {
dbHealth := s.store.HealthCheck(r.Context())
response["database"] = map[string]any{
"status": dbHealth.Status,
"query_latency_ms": float64(dbHealth.QueryLatency) / 1e6,
"pool": dbHealth.PoolStats,
"warning": dbHealth.Warning,
}
}
// Add embedding model info
if s.embedSvc != nil {
response["embeddingModel"] = map[string]any{
"name": s.embedSvc.Name(),
"version": s.embedSvc.Version(),
"dimensions": s.embedSvc.Dimensions(),
}
}
// Add vector cache stats
if s.vectorClient != nil {
if count, err := s.vectorClient.Count(r.Context()); err == nil {
response["vectorCount"] = count
}
cacheSize, cacheMax := s.vectorClient.CacheStats()
response["vectorCache"] = map[string]any{
"size": cacheSize,
"max_size": cacheMax,
}
}
// Include project-specific observation count if project is specified
if project != "" {
count, err := s.getCachedObservationCount(r.Context(), project)
if err == nil {
response["projectObservations"] = count
response["project"] = project
}
}
// Add rate limiter stats
if s.rateLimiter != nil {
response["rateLimiter"] = s.rateLimiter.Stats()
}
// Add circuit breaker metrics
if s.processor != nil {
response["circuitBreaker"] = s.processor.CircuitBreakerMetrics()
}
writeJSON(w, response)
}
// handleGetRetrievalStats returns detailed retrieval statistics.
func (s *Service) handleGetRetrievalStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
stats := s.GetRetrievalStats(project)
writeJSON(w, stats)
}
// handleGetRecentQueries returns recent search queries for analytics.
func (s *Service) handleGetRecentQueries(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
limit := gorm.ParseLimitParam(r, 20)
queries := s.getRecentSearchQueries(project, limit)
writeJSON(w, map[string]any{
"queries": queries,
"count": len(queries),
"project": project,
})
}
// handleGetSearchAnalytics returns comprehensive search analytics and statistics.
func (s *Service) handleGetSearchAnalytics(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
// Get all recent queries for analysis
queries := s.getRecentSearchQueries(project, maxRecentQueries)
// Calculate analytics
totalQueries := len(queries)
vectorSearches := 0
totalResults := 0
zeroResultQueries := 0
queryTypes := make(map[string]int)
topKeywords := make(map[string]int)
for _, q := range queries {
if q.UsedVector {
vectorSearches++
}
totalResults += q.Results
if q.Results == 0 {
zeroResultQueries++
}
queryTypes[q.Type]++
// Extract keywords (simple word tokenization using iterator)
for word := range strings.FieldsSeq(strings.ToLower(q.Query)) {
if len(word) > 3 { // Skip short words
topKeywords[word]++
}
}
}
// Sort keywords by frequency
type keywordCount struct {
Keyword string `json:"keyword"`
Count int `json:"count"`
}
sortedKeywords := make([]keywordCount, 0, len(topKeywords))
for kw, count := range topKeywords {
sortedKeywords = append(sortedKeywords, keywordCount{Keyword: kw, Count: count})
}
sort.Slice(sortedKeywords, func(i, j int) bool {
return sortedKeywords[i].Count > sortedKeywords[j].Count
})
if len(sortedKeywords) > 10 {
sortedKeywords = sortedKeywords[:10]
}
// Calculate averages
avgResults := float64(0)
vectorSearchRate := float64(0)
zeroResultRate := float64(0)
if totalQueries > 0 {
avgResults = float64(totalResults) / float64(totalQueries)
vectorSearchRate = float64(vectorSearches) / float64(totalQueries) * 100
zeroResultRate = float64(zeroResultQueries) / float64(totalQueries) * 100
}
writeJSON(w, map[string]any{
"total_queries": totalQueries,
"vector_search_rate": vectorSearchRate,
"avg_results": avgResults,
"zero_result_rate": zeroResultRate,
"query_types": queryTypes,
"top_keywords": sortedKeywords,
"project": project,
})
}
// handleVectorHealth returns comprehensive health information about the vector database.
func (s *Service) handleVectorHealth(w http.ResponseWriter, r *http.Request) {
if s.vectorClient == nil {
http.Error(w, "vector client not initialized", http.StatusServiceUnavailable)
return
}
stats, err := s.vectorClient.GetHealthStats(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Add additional computed metrics
healthScore := 100.0
var warnings []string
// Penalize for stale vectors
if stats.TotalVectors > 0 {
staleRatio := float64(stats.StaleVectors) / float64(stats.TotalVectors)
if staleRatio > 0 {
healthScore -= staleRatio * 50 // Up to 50 points off for stale vectors
warnings = append(warnings, formatWarning("%.1f%% vectors need rebuild", staleRatio*100))
}
}
// Check cache effectiveness
cacheHitRate := stats.EmbeddingCache.HitRate()
if cacheHitRate < 20 && (stats.EmbeddingCache.EmbeddingHits+stats.EmbeddingCache.EmbeddingMisses) > 100 {
healthScore -= 10
warnings = append(warnings, formatWarning("Low cache hit rate: %.1f%%", cacheHitRate))
}
// Penalize if rebuild is needed
if stats.NeedsRebuild {
healthScore -= 20
warnings = append(warnings, "Vector rebuild recommended: "+stats.RebuildReason)
}
if healthScore < 0 {
healthScore = 0
}
status := "healthy"
if healthScore < 50 {
status = "unhealthy"
} else if healthScore < 80 {
status = "degraded"
}
writeJSON(w, map[string]any{
"status": status,
"health_score": healthScore,
"warnings": warnings,
"stats": stats,
"cache_hit_rate": cacheHitRate,
})
}
// UpdateObservationRequest is the request body for updating an observation.
type UpdateObservationRequest struct {
Title *string `json:"title,omitempty"`
Subtitle *string `json:"subtitle,omitempty"`
Narrative *string `json:"narrative,omitempty"`
Scope *string `json:"scope,omitempty"`
Facts []string `json:"facts,omitempty"`
Concepts []string `json:"concepts,omitempty"`
FilesRead []string `json:"files_read,omitempty"`
FilesModified []string `json:"files_modified,omitempty"`
}
// handleUpdateObservation updates an existing observation.
// PUT /api/observations/{id}
func (s *Service) handleUpdateObservation(w http.ResponseWriter, r *http.Request) {
// Parse observation ID from URL
id, ok := parseIDParam(w, r.PathValue("id"), "observation")
if !ok {
return
}
// Parse request body
var req UpdateObservationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
// Build update struct - only include fields that were provided
update := &gorm.ObservationUpdate{}
if req.Title != nil {
update.Title = req.Title
}
if req.Subtitle != nil {
update.Subtitle = req.Subtitle
}
if req.Narrative != nil {
update.Narrative = req.Narrative
}
if req.Facts != nil {
update.Facts = &req.Facts
}
if req.Concepts != nil {
update.Concepts = &req.Concepts
}
if req.FilesRead != nil {
update.FilesRead = &req.FilesRead
}
if req.FilesModified != nil {
update.FilesModified = &req.FilesModified
}
if req.Scope != nil {
// Validate scope
if *req.Scope != "project" && *req.Scope != "global" {
http.Error(w, "scope must be 'project' or 'global'", http.StatusBadRequest)
return
}
update.Scope = req.Scope
}
// Update the observation
updatedObs, err := s.observationStore.UpdateObservation(r.Context(), id, update)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
http.Error(w, "failed to update observation: "+err.Error(), http.StatusInternalServerError)
return
}
// Trigger vector resync for the updated observation
if s.vectorSync != nil {
s.asyncVectorSync(func() {
if err := s.vectorSync.SyncObservation(s.ctx, updatedObs); err != nil {
log.Warn().Err(err).Int64("id", id).Msg("Failed to resync observation vectors after update")
}
})
}
// Broadcast update event
s.sseBroadcaster.Broadcast(map[string]any{
"type": "observation_updated",
"id": id,
})
writeJSON(w, map[string]any{
"observation": updatedObs,
"message": "observation updated successfully",
})
}
// handleGetObservationByID returns a single observation by ID.
// GET /api/observations/{id}
func (s *Service) handleGetObservationByID(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r.PathValue("id"), "observation")
if !ok {
return
}
obs, err := s.observationStore.GetObservationByID(r.Context(), id)
if err != nil {
http.Error(w, "failed to get observation: "+err.Error(), http.StatusInternalServerError)
return
}
if obs == nil {
http.Error(w, "observation not found", http.StatusNotFound)
return
}
writeJSON(w, obs)
}
// handleGraphStats returns graph statistics for the dashboard.
// Uses relation data to compute knowledge graph metrics.
func (s *Service) handleGraphStats(w http.ResponseWriter, r *http.Request) {
// Get relation count (edges) - this represents the knowledge graph
edgeCount, err := s.relationStore.GetTotalRelationCount(r.Context())
if err != nil {
edgeCount = 0
}
// Count by relation type
edgeTypes := make(map[string]int)
for _, t := range models.AllRelationTypes {
relations, err := s.relationStore.GetRelationsByType(r.Context(), t, 10000)
if err == nil {
edgeTypes[string(t)] = len(relations)
}
}
// Get unique observation IDs involved in relations (approximate node count)
// For now, use edge count as a proxy - each edge has 2 nodes
nodeCount := 0
if edgeCount > 0 {
// Rough estimate: unique nodes ≈ edges * 1.5 (since nodes can have multiple edges)
nodeCount = int(float64(edgeCount) * 1.5)
}
// Calculate average degree
var avgDegree float64
if nodeCount > 0 {
avgDegree = float64(edgeCount*2) / float64(nodeCount)
}
// Graph is enabled if we have any edges (relations)
enabled := edgeCount > 0
writeJSON(w, map[string]any{
"enabled": enabled,
"nodeCount": nodeCount,
"edgeCount": edgeCount,
"avgDegree": avgDegree,
"maxDegree": 0,
"minDegree": 0,
"medianDegree": 0.0,
"edgeTypes": edgeTypes,
"config": map[string]any{
"maxHops": 2,
"branchFactor": 10,
"edgeWeight": 0.3,
"rebuildIntervalMin": 30,
},
})
}
// handleVectorMetrics returns vector database metrics for the dashboard.
// Returns enabled: false if vector features are not available.
func (s *Service) handleVectorMetrics(w http.ResponseWriter, r *http.Request) {
if s.vectorClient == nil {
writeJSON(w, map[string]any{
"enabled": false,
"message": "Vector database not initialized",
})
return
}
// Get cache stats from vector client
cacheSize, cacheMax := s.vectorClient.CacheStats()
cacheStats := s.vectorClient.GetCacheStats()
count, _ := s.vectorClient.Count(r.Context())
uptime := time.Since(s.startTime).Round(time.Second).String()
// Calculate total queries from cache hits/misses
totalQueries := cacheStats.EmbeddingHits + cacheStats.EmbeddingMisses + cacheStats.ResultHits + cacheStats.ResultMisses
totalHits := cacheStats.EmbeddingHits + cacheStats.ResultHits
totalMisses := cacheStats.EmbeddingMisses + cacheStats.ResultMisses
writeJSON(w, map[string]any{
"enabled": true,
"queries": map[string]any{
"total": totalQueries,
"hubOnly": 0,
"hybrid": 0,
"onDemand": 0,
"graph": 0,
},
"latency": map[string]any{
"avg": "0ms",
"p50": "0ms",
"p95": "0ms",
"p99": "0ms",
"avgHub": "0ms",
"avgRecompute": "0ms",
},
"storage": map[string]any{
"totalDocuments": count,
"hubDocuments": 0,
"storedEmbeddings": count,
"savingsPercent": 0.0,
"recomputedTotal": 0,
},
"cache": map[string]any{
"hits": totalHits,
"misses": totalMisses,
"hitRate": cacheStats.HitRate(),
"size": cacheSize,
"maxSize": cacheMax,
},
"graph": map[string]any{
"traversals": 0,
"avgDepth": 0.0,
},
"uptime": uptime,
})
}
+680
View File
@@ -0,0 +1,680 @@
// Package worker provides import, export, and archive HTTP handlers.
package worker
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/lukaszraczylo/claude-mnemonic/pkg/similarity"
"github.com/rs/zerolog/log"
)
// BulkImportRequest is the request body for bulk observation import.
type BulkImportRequest struct {
Project string `json:"project"`
Observations []BulkObservationInput `json:"observations"`
}
// BulkObservationInput represents a single observation in bulk import.
type BulkObservationInput struct {
Type string `json:"type"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
Narrative string `json:"narrative,omitempty"`
Scope string `json:"scope,omitempty"`
Facts []string `json:"facts,omitempty"`
Concepts []string `json:"concepts,omitempty"`
FilesRead []string `json:"files_read,omitempty"`
FilesModified []string `json:"files_modified,omitempty"`
}
// BulkImportResponse contains the result of a bulk import operation.
type BulkImportResponse struct {
Errors []string `json:"errors,omitempty"`
Imported int `json:"imported"`
Failed int `json:"failed"`
SkippedDuplicates int `json:"skipped_duplicates,omitempty"`
}
// handleBulkImport handles bulk import of observations.
// This is useful for migrating data or importing observations from external sources.
func (s *Service) handleBulkImport(w http.ResponseWriter, r *http.Request) {
// Rate limit bulk operations to prevent DoS
if s.bulkOpLimiter != nil && !s.bulkOpLimiter.CanExecute() {
remaining := s.bulkOpLimiter.CooldownRemaining()
http.Error(w, fmt.Sprintf("bulk import rate limited, retry in %d seconds", remaining), http.StatusTooManyRequests)
return
}
var req BulkImportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if req.Project == "" {
http.Error(w, "project is required", http.StatusBadRequest)
return
}
// Validate project name to prevent path traversal
if err := ValidateProjectName(req.Project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.Observations) == 0 {
http.Error(w, "at least one observation is required", http.StatusBadRequest)
return
}
// Limit batch size to prevent overwhelming the system
maxBatchSize := 100
if len(req.Observations) > maxBatchSize {
http.Error(w, fmt.Sprintf("batch size exceeds maximum of %d", maxBatchSize), http.StatusBadRequest)
return
}
// Create a synthetic session for bulk import
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), fmt.Sprintf("bulk-import-%d", time.Now().UnixMilli()), req.Project, "bulk import")
if err != nil {
http.Error(w, "failed to create import session: "+err.Error(), http.StatusInternalServerError)
return
}
var imported, failed, skippedDupes int
var errors []string
// Track imported observations for deduplication within the batch
importedObs := make([]*models.Observation, 0, len(req.Observations))
// Deduplication threshold - observations more similar than this are considered duplicates
const dedupThreshold = 0.7
for i, obsInput := range req.Observations {
// Validate observation type using O(1) map lookup
if !IsValidObservationType(obsInput.Type) {
failed++
errors = append(errors, fmt.Sprintf("observation %d: invalid type '%s'", i, obsInput.Type))
continue
}
// Build parsed observation
parsedObs := &models.ParsedObservation{
Type: models.ObservationType(obsInput.Type),
Title: obsInput.Title,
Subtitle: obsInput.Subtitle,
Facts: obsInput.Facts,
Narrative: obsInput.Narrative,
Concepts: obsInput.Concepts,
FilesRead: obsInput.FilesRead,
FilesModified: obsInput.FilesModified,
Scope: models.ObservationScope(obsInput.Scope),
}
// Convert to temporary observation for similarity check
tempObs := &models.Observation{
Title: sql.NullString{String: parsedObs.Title, Valid: parsedObs.Title != ""},
Subtitle: sql.NullString{String: parsedObs.Subtitle, Valid: parsedObs.Subtitle != ""},
Narrative: sql.NullString{String: parsedObs.Narrative, Valid: parsedObs.Narrative != ""},
}
// Check for duplicates within this import batch
if similarity.IsSimilarToAny(tempObs, importedObs, dedupThreshold) {
skippedDupes++
continue
}
// Store observation
obsID, _, err := s.observationStore.StoreObservation(
r.Context(),
fmt.Sprintf("bulk-import-%d", sessionID),
req.Project,
parsedObs,
0, // prompt number
0, // discovery tokens
)
if err != nil {
failed++
errors = append(errors, fmt.Sprintf("observation %d: %v", i, err))
continue
}
// Sync to vector DB asynchronously with rate limiting
if s.vectorSync != nil {
s.asyncVectorSync(func() {
// Use service context as parent to respect shutdown signals
ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)
defer cancel()
obs, err := s.observationStore.GetObservationByID(ctx, obsID)
if err == nil && obs != nil {
if syncErr := s.vectorSync.SyncObservation(ctx, obs); syncErr != nil {
if s.ctx.Err() == nil { // Don't log during shutdown
log.Debug().Err(syncErr).Int64("id", obsID).Msg("Failed to sync observation during bulk import")
}
}
}
})
}
// Track for deduplication of subsequent observations in this batch
importedObs = append(importedObs, tempObs)
imported++
}
log.Info().
Str("project", req.Project).
Int("imported", imported).
Int("failed", failed).
Int("skipped_duplicates", skippedDupes).
Msg("Bulk import completed")
// Invalidate observation count cache after import
if imported > 0 {
if req.Project != "" {
s.invalidateObsCountCache(req.Project)
} else {
s.invalidateAllObsCountCache()
}
}
// Broadcast observation event for dashboard refresh
s.sseBroadcaster.Broadcast(map[string]any{
"type": "observation",
"action": "bulk_import",
"project": req.Project,
"count": imported,
})
writeJSON(w, BulkImportResponse{
Imported: imported,
Failed: failed,
SkippedDuplicates: skippedDupes,
Errors: errors,
})
}
// ArchiveRequest is the request body for archiving observations.
type ArchiveRequest struct {
Project string `json:"project,omitempty"`
Reason string `json:"reason,omitempty"`
IDs []int64 `json:"ids,omitempty"`
MaxAgeDays int `json:"max_age_days,omitempty"`
}
// handleArchiveObservations archives observations by ID or by age.
// Supports batch archival with error tracking per observation.
func (s *Service) handleArchiveObservations(w http.ResponseWriter, r *http.Request) {
var req ArchiveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
var archivedIDs []int64
var failedIDs []int64
var errors []string
var err error
if len(req.IDs) > 0 {
// Archive specific observations with parallel processing for large batches
if len(req.IDs) > 5 {
// Use parallel archival for batches larger than 5
type archiveResult struct {
err error
id int64
}
results := make(chan archiveResult, len(req.IDs))
// Limit concurrency to avoid overwhelming the database
sem := make(chan struct{}, 5)
var wg sync.WaitGroup
for _, id := range req.IDs {
wg.Add(1)
go func(obsID int64) {
defer wg.Done()
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
archErr := s.observationStore.ArchiveObservation(r.Context(), obsID, req.Reason)
results <- archiveResult{id: obsID, err: archErr}
}(id)
}
// Close results channel when all goroutines complete
go func() {
wg.Wait()
close(results)
}()
// Collect results
for res := range results {
if res.err != nil {
log.Warn().Err(res.err).Int64("id", res.id).Msg("Failed to archive observation")
failedIDs = append(failedIDs, res.id)
errors = append(errors, fmt.Sprintf("id %d: %v", res.id, res.err))
} else {
archivedIDs = append(archivedIDs, res.id)
}
}
} else {
// Sequential for small batches
for _, id := range req.IDs {
if archErr := s.observationStore.ArchiveObservation(r.Context(), id, req.Reason); archErr != nil {
log.Warn().Err(archErr).Int64("id", id).Msg("Failed to archive observation")
failedIDs = append(failedIDs, id)
errors = append(errors, fmt.Sprintf("id %d: %v", id, archErr))
} else {
archivedIDs = append(archivedIDs, id)
}
}
}
} else if req.Project != "" || req.MaxAgeDays > 0 {
// Archive by age
archivedIDs, err = s.observationStore.ArchiveOldObservations(r.Context(), req.Project, req.MaxAgeDays, req.Reason)
if err != nil {
http.Error(w, "failed to archive: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "either 'ids' or 'project'/'max_age_days' is required", http.StatusBadRequest)
return
}
log.Info().
Str("project", req.Project).
Int("archived", len(archivedIDs)).
Int("failed", len(failedIDs)).
Msg("Observations archived")
// Invalidate cache if any observations were archived
if len(archivedIDs) > 0 {
if req.Project != "" {
s.invalidateObsCountCache(req.Project)
} else {
s.invalidateAllObsCountCache()
}
}
response := map[string]any{
"archived_count": len(archivedIDs),
"archived_ids": archivedIDs,
}
if len(failedIDs) > 0 {
response["failed_count"] = len(failedIDs)
response["failed_ids"] = failedIDs
response["errors"] = errors
}
writeJSON(w, response)
}
// handleUnarchiveObservation restores an archived observation.
func (s *Service) handleUnarchiveObservation(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid observation id", http.StatusBadRequest)
return
}
if err := s.observationStore.UnarchiveObservation(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Invalidate all caches since we don't know the project
s.invalidateAllObsCountCache()
writeJSON(w, map[string]any{
"success": true,
"id": id,
})
}
// handleGetArchivedObservations returns archived observations.
func (s *Service) handleGetArchivedObservations(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
limit := gorm.ParseLimitParam(r, DefaultObservationsLimit)
observations, err := s.observationStore.GetArchivedObservations(r.Context(), project, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if observations == nil {
observations = []*models.Observation{}
}
writeJSON(w, observations)
}
// handleGetArchivalStats returns archival statistics.
func (s *Service) handleGetArchivalStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
stats, err := s.observationStore.GetArchivalStats(r.Context(), project)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, stats)
}
// handleExportObservations exports observations in JSON or CSV format.
// Supports query parameters: project, format (json/csv), scope, type, limit.
func (s *Service) handleExportObservations(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
format := r.URL.Query().Get("format")
if format == "" {
format = "json"
}
scope := r.URL.Query().Get("scope") // project, global, or empty for all
obsType := r.URL.Query().Get("type") // bugfix, feature, etc.
limit := gorm.ParseLimitParamWithMax(r, 1000, 5000) // Higher limit for exports, capped at 5000
// Validate format
if format != "json" && format != "csv" {
http.Error(w, "format must be 'json' or 'csv'", http.StatusBadRequest)
return
}
// Get observations with filters
ctx := r.Context()
var observations []*models.Observation
var err error
if project != "" {
observations, _, err = s.observationStore.GetObservationsByProjectStrictPaginated(ctx, project, limit, 0)
} else {
observations, _, err = s.observationStore.GetAllRecentObservationsPaginated(ctx, limit, 0)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Apply additional filters
if scope != "" || obsType != "" {
filtered := make([]*models.Observation, 0, len(observations))
for _, obs := range observations {
if scope != "" && string(obs.Scope) != scope {
continue
}
if obsType != "" && string(obs.Type) != obsType {
continue
}
filtered = append(filtered, obs)
}
observations = filtered
}
// Generate filename
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("observations-%s.%s", timestamp, format)
if project != "" {
// Sanitize project name for filename
sanitized := strings.ReplaceAll(project, "/", "_")
sanitized = strings.ReplaceAll(sanitized, "\\", "_")
if len(sanitized) > 50 {
sanitized = sanitized[:50]
}
filename = fmt.Sprintf("observations-%s-%s.%s", sanitized, timestamp, format)
}
switch format {
case "csv":
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
s.writeObservationsCSV(w, observations)
default: // json
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
writeJSON(w, map[string]any{
"exported_at": time.Now().Format(time.RFC3339),
"project": project,
"count": len(observations),
"observations": observations,
})
}
}
// writeObservationsCSV writes observations in CSV format.
// Uses fmt.Fprintf directly to avoid intermediate string allocations.
func (s *Service) writeObservationsCSV(w http.ResponseWriter, observations []*models.Observation) {
// Write CSV header
_, _ = io.WriteString(w, "id,type,scope,project,title,subtitle,narrative,concepts,facts,created_at,importance_score\n")
for _, obs := range observations {
// Write directly to avoid string allocation per row
_, _ = fmt.Fprintf(w, "%d,%s,%s,%s,%s,%s,%s,%s,%s,%s,%.2f\n",
obs.ID,
obs.Type,
obs.Scope,
escapeCsvField(obs.Project),
escapeCsvField(obs.Title.String),
escapeCsvField(obs.Subtitle.String),
escapeCsvField(obs.Narrative.String),
escapeCsvField(strings.Join(obs.Concepts, ";")),
escapeCsvField(strings.Join(obs.Facts, ";")),
obs.CreatedAt,
obs.ImportanceScore,
)
}
}
// escapeCsvField escapes a field for CSV output.
func escapeCsvField(s string) string {
// If field contains comma, quote, or newline, wrap in quotes and escape quotes
if strings.ContainsAny(s, ",\"\n\r") {
s = strings.ReplaceAll(s, "\"", "\"\"")
return "\"" + s + "\""
}
return s
}
// BulkStatusRequest represents a request to update status for multiple observations.
type BulkStatusRequest struct {
Action string `json:"action"`
Reason string `json:"reason,omitempty"`
IDs []int64 `json:"ids"`
Feedback int `json:"feedback,omitempty"`
}
// handleBulkStatusUpdate updates status for multiple observations in one request.
func (s *Service) handleBulkStatusUpdate(w http.ResponseWriter, r *http.Request) {
var req BulkStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
http.Error(w, "ids is required", http.StatusBadRequest)
return
}
if len(req.IDs) > 500 {
http.Error(w, "maximum 500 ids per request", http.StatusBadRequest)
return
}
ctx := r.Context()
var updated, failed int
var errors []string
switch req.Action {
case "supersede":
for _, id := range req.IDs {
if err := s.observationStore.MarkAsSuperseded(ctx, id); err != nil {
failed++
errors = append(errors, fmt.Sprintf("id %d: %v", id, err))
} else {
updated++
}
}
case "archive":
for _, id := range req.IDs {
if err := s.observationStore.ArchiveObservation(ctx, id, req.Reason); err != nil {
failed++
errors = append(errors, fmt.Sprintf("id %d: %v", id, err))
} else {
updated++
}
}
case "set_feedback":
if req.Feedback < -1 || req.Feedback > 1 {
http.Error(w, "feedback must be -1, 0, or 1", http.StatusBadRequest)
return
}
for _, id := range req.IDs {
if err := s.observationStore.UpdateObservationFeedback(ctx, id, req.Feedback); err != nil {
failed++
errors = append(errors, fmt.Sprintf("id %d: %v", id, err))
} else {
updated++
}
}
default:
http.Error(w, "action must be 'supersede', 'archive', or 'set_feedback'", http.StatusBadRequest)
return
}
// Invalidate cache for archive action (affects observation counts)
if req.Action == "archive" && updated > 0 {
// No project info available, invalidate all caches
s.invalidateAllObsCountCache()
}
response := map[string]any{
"action": req.Action,
"updated": updated,
"failed": failed,
}
if len(errors) > 0 {
response["errors"] = errors
}
writeJSON(w, response)
}
// handleFindDuplicates finds potential duplicate observations using similarity clustering.
// Returns groups of similar observations that may be candidates for merging or archival.
func (s *Service) handleFindDuplicates(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
thresholdStr := r.URL.Query().Get("threshold")
limit := gorm.ParseLimitParam(r, 100)
// Parse threshold (default 0.6 = 60% similarity)
threshold := 0.6
if thresholdStr != "" {
if t, err := strconv.ParseFloat(thresholdStr, 64); err == nil && t > 0 && t < 1 {
threshold = t
}
}
// Get recent observations
ctx := r.Context()
var observations []*models.Observation
var err error
if project != "" {
observations, _, err = s.observationStore.GetObservationsByProjectStrictPaginated(ctx, project, limit, 0)
} else {
observations, _, err = s.observationStore.GetAllRecentObservationsPaginated(ctx, limit, 0)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(observations) < 2 {
writeJSON(w, map[string]any{
"duplicate_groups": []any{},
"total_checked": len(observations),
"threshold": threshold,
})
return
}
// Find duplicates using similarity comparison
type duplicateGroup struct {
Observations []map[string]any `json:"observations"`
Similarity float64 `json:"similarity"`
}
groups := []duplicateGroup{}
processed := make(map[int64]bool)
for i, obs1 := range observations {
if processed[obs1.ID] {
continue
}
terms1 := similarity.ExtractObservationTerms(obs1)
if len(terms1) == 0 {
continue
}
group := duplicateGroup{
Observations: []map[string]any{obs1.ToMap()},
Similarity: 1.0,
}
for j := i + 1; j < len(observations); j++ {
obs2 := observations[j]
if processed[obs2.ID] {
continue
}
terms2 := similarity.ExtractObservationTerms(obs2)
sim := similarity.JaccardSimilarity(terms1, terms2)
if sim >= threshold {
obsMap := obs2.ToMap()
obsMap["similarity_to_first"] = sim
group.Observations = append(group.Observations, obsMap)
group.Similarity = min(group.Similarity, sim)
processed[obs2.ID] = true
}
}
if len(group.Observations) > 1 {
processed[obs1.ID] = true
groups = append(groups, group)
}
}
// Sort groups by size (largest first)
sort.Slice(groups, func(i, j int) bool {
return len(groups[i].Observations) > len(groups[j].Observations)
})
writeJSON(w, map[string]any{
"duplicate_groups": groups,
"total_checked": len(observations),
"groups_found": len(groups),
"threshold": threshold,
"project": project,
})
}
+2 -2
View File
@@ -46,7 +46,7 @@ func (s *Service) handleGetRelationGraph(w http.ResponseWriter, r *http.Request)
// Get depth parameter (default 2)
depth := 2
if depthStr := r.URL.Query().Get("depth"); depthStr != "" {
if d, err := strconv.Atoi(depthStr); err == nil && d > 0 && d <= 5 {
if d, parseErr := strconv.Atoi(depthStr); parseErr == nil && d > 0 && d <= 5 {
depth = d
}
}
@@ -72,7 +72,7 @@ func (s *Service) handleGetRelatedObservations(w http.ResponseWriter, r *http.Re
// Get minimum confidence parameter (default 0.4)
minConfidence := 0.4
if confStr := r.URL.Query().Get("min_confidence"); confStr != "" {
if c, err := strconv.ParseFloat(confStr, 64); err == nil && c >= 0 && c <= 1 {
if c, parseErr := strconv.ParseFloat(confStr, 64); parseErr == nil && c >= 0 && c <= 1 {
minConfidence = c
}
}
+19 -23
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// FeedbackRequest represents a user feedback submission.
@@ -41,11 +42,9 @@ func (s *Service) handleObservationFeedback(w http.ResponseWriter, r *http.Reque
return
}
// Get required components
s.initMu.RLock()
// Get required components (initMu.RLock held by requireReady middleware)
observationStore := s.observationStore
scoreCalculator := s.scoreCalculator
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -94,10 +93,9 @@ func (s *Service) handleObservationFeedback(w http.ResponseWriter, r *http.Reque
func (s *Service) handleGetScoringStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
recalculator := s.recalculator
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -129,9 +127,8 @@ func (s *Service) handleGetTopObservations(w http.ResponseWriter, r *http.Reques
limit := parseIntParam(r, "limit", 10)
project := r.URL.Query().Get("project")
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -157,9 +154,8 @@ func (s *Service) handleGetMostRetrieved(w http.ResponseWriter, r *http.Request)
limit := parseIntParam(r, "limit", 10)
project := r.URL.Query().Get("project")
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -190,10 +186,9 @@ func (s *Service) handleExplainScore(w http.ResponseWriter, r *http.Request) {
return
}
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
scoreCalculator := s.scoreCalculator
s.initMu.RUnlock()
if observationStore == nil || scoreCalculator == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -244,10 +239,9 @@ func (s *Service) handleUpdateConceptWeight(w http.ResponseWriter, r *http.Reque
return
}
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
recalculator := s.recalculator
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -278,9 +272,8 @@ func (s *Service) handleUpdateConceptWeight(w http.ResponseWriter, r *http.Reque
// handleGetConceptWeights returns all concept weights.
// GET /api/scoring/concepts
func (s *Service) handleGetConceptWeights(w http.ResponseWriter, r *http.Request) {
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -299,20 +292,22 @@ func (s *Service) handleGetConceptWeights(w http.ResponseWriter, r *http.Request
// handleTriggerRecalculation triggers an immediate score recalculation.
// POST /api/scoring/recalculate
func (s *Service) handleTriggerRecalculation(w http.ResponseWriter, r *http.Request) {
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
recalculator := s.recalculator
s.initMu.RUnlock()
if recalculator == nil {
http.Error(w, "recalculator not available", http.StatusServiceUnavailable)
return
}
// Run recalculation in background
// Run recalculation in background with independent context
s.wg.Add(1)
go func() {
if err := recalculator.RecalculateNow(r.Context()); err != nil {
// Log error but don't block response
_ = err // Explicitly ignore - background operation
defer s.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := recalculator.RecalculateNow(ctx); err != nil {
log.Error().Err(err).Msg("Background recalculation failed")
}
}()
@@ -336,16 +331,17 @@ func (s *Service) incrementRetrievalCounts(ids []int64) {
return
}
s.initMu.RLock()
// initMu.RLock held by requireReady middleware (caller is always behind requireReady)
store := s.observationStore
s.initMu.RUnlock()
if store == nil {
return
}
// Increment in background to not block response
s.wg.Add(1)
go func() {
defer s.wg.Done()
// Create a new context with timeout for the background operation
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
+402
View File
@@ -0,0 +1,402 @@
// Package worker provides session-related HTTP handlers.
package worker
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/lukaszraczylo/claude-mnemonic/internal/privacy"
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/session"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// SessionInitRequest is the request body for session initialization.
type SessionInitRequest struct {
ClaudeSessionID string `json:"claudeSessionId"`
Project string `json:"project"`
Prompt string `json:"prompt"`
MatchedObservations int `json:"matchedObservations"`
}
// SessionInitResponse is the response for session initialization.
type SessionInitResponse struct {
Reason string `json:"reason,omitempty"`
SessionDBID int64 `json:"sessionDbId"`
PromptNumber int `json:"promptNumber"`
Skipped bool `json:"skipped,omitempty"`
}
// DuplicatePromptWindowSeconds is the time window for detecting duplicate prompt submissions.
// If the same prompt text is seen within this window, it's considered a duplicate hook invocation.
const DuplicatePromptWindowSeconds = 10
// handleSessionInit handles session initialization from user-prompt hook.
// This handler is idempotent - duplicate requests within a short time window
// return the existing prompt data without creating duplicates.
func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
var req SessionInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Reject requests from internal CLI calls (callClaudeCLI runs from /tmp with no session ID).
// These are the memory extraction agent's own prompts, not real user prompts.
if req.ClaudeSessionID == "" {
log.Debug().Str("project", req.Project).Msg("Rejecting session init with empty claude session ID (internal call)")
writeJSON(w, SessionInitResponse{
Skipped: true,
Reason: "internal",
})
return
}
// Reject prompts that contain the internal system prompt (from SDK processor).
// The SDK processor sets CLAUDE_MNEMONIC_INTERNAL=1 on the subprocess env,
// but Claude Code does NOT propagate custom env vars to hook subprocesses,
// so that guard fails and the system prompt leaks into the database as a
// regular user prompt. This server-side check catches those cases reliably.
if strings.Contains(req.Prompt, "You are a memory extraction agent for Claude Code sessions") {
log.Debug().Str("project", req.Project).Msg("Rejecting session init with internal system prompt")
writeJSON(w, SessionInitResponse{
Skipped: true,
Reason: "internal",
})
return
}
// Privacy check
if privacy.IsEntirelyPrivate(req.Prompt) {
// Create session but skip processing
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, "")
promptNum, _ := s.sessionStore.IncrementPromptCounter(r.Context(), sessionID)
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: promptNum,
Skipped: true,
Reason: "private",
})
return
}
// Clean prompt
cleanedPrompt := privacy.Clean(req.Prompt)
// DUPLICATE DETECTION: Check if this exact prompt was already saved recently.
// This prevents the bug where the hook fires multiple times for the same user action,
// creating many duplicate prompts with incrementing numbers.
if existingID, existingNum, found := s.promptStore.FindRecentPromptByText(r.Context(), req.ClaudeSessionID, cleanedPrompt, DuplicatePromptWindowSeconds); found {
// Get or create session (idempotent)
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
log.Debug().
Int64("sessionId", sessionID).
Int("promptNumber", existingNum).
Int64("promptId", existingID).
Msg("Duplicate prompt detected - returning existing")
// Return existing prompt data without incrementing or saving again
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: existingNum,
})
return
}
// CROSS-SESSION DUPLICATE DETECTION: Same prompt text from ANY session within the window.
// This catches duplicates when the SDK processor's callClaudeCLI subprocess fires
// UserPromptSubmit with a different Claude session ID than the original hook.
if _, existingNum, found := s.promptStore.FindRecentPromptByTextGlobal(r.Context(), cleanedPrompt, DuplicatePromptWindowSeconds); found {
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
log.Debug().
Int64("sessionId", sessionID).
Int("promptNumber", existingNum).
Msg("Cross-session duplicate prompt detected")
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: existingNum,
})
return
}
// Create session (idempotent)
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Increment prompt counter
promptNum, err := s.sessionStore.IncrementPromptCounter(r.Context(), sessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Save user prompt with matched observation count
promptID, err := s.promptStore.SaveUserPromptWithMatches(r.Context(), req.ClaudeSessionID, promptNum, cleanedPrompt, req.MatchedObservations)
if err != nil {
log.Warn().Err(err).Msg("Failed to save user prompt")
// Non-fatal: continue with session initialization
} else if s.vectorSync != nil {
// Sync to vector DB asynchronously (non-blocking)
now := time.Now()
promptWithSession := &models.UserPromptWithSession{
UserPrompt: models.UserPrompt{
ID: promptID,
ClaudeSessionID: req.ClaudeSessionID,
PromptNumber: promptNum,
PromptText: cleanedPrompt,
MatchedObservations: req.MatchedObservations,
CreatedAt: now.Format(time.RFC3339),
CreatedAtEpoch: now.UnixMilli(),
},
Project: req.Project,
SDKSessionID: req.ClaudeSessionID,
}
s.asyncVectorSync(func() {
// Use service context as parent to respect shutdown signals
ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)
defer cancel()
if err := s.vectorSync.SyncUserPrompt(ctx, promptWithSession); err != nil {
if s.ctx.Err() == nil { // Don't log during shutdown
log.Warn().Err(err).Int64("id", promptID).Msg("Failed to sync user prompt to sqlite-vec")
}
}
})
}
log.Info().
Int64("sessionId", sessionID).
Int("promptNumber", promptNum).
Str("project", req.Project).
Msg("Session initialized")
// Broadcast prompt event for dashboard refresh
s.sseBroadcaster.Broadcast(map[string]any{
"type": "prompt",
"action": "created",
"project": req.Project,
})
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: promptNum,
})
}
// SessionStartRequest is the request body for starting SDK agent.
type SessionStartRequest struct {
UserPrompt string `json:"userPrompt"`
PromptNumber int `json:"promptNumber"`
}
// handleSessionStart handles SDK agent session start.
func (s *Service) handleSessionStart(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid session id", http.StatusBadRequest)
return
}
var req SessionStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Initialize session in manager
sess, err := s.sessionManager.InitializeSession(r.Context(), id, req.UserPrompt, req.PromptNumber)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
// Session is now registered. Observations will be processed
// asynchronously by the background queue processor (processQueue in service.go).
log.Info().
Int64("sessionId", id).
Int("promptNumber", req.PromptNumber).
Msg("SDK agent session initialized")
s.broadcastProcessingStatus()
w.WriteHeader(http.StatusOK)
}
// ObservationRequest is the request body for posting observations.
type ObservationRequest struct {
ClaudeSessionID string `json:"claudeSessionId"`
Project string `json:"project"`
ToolName string `json:"tool_name"`
ToolInput any `json:"tool_input"`
ToolResponse any `json:"tool_response"`
CWD string `json:"cwd"`
}
// handleObservation handles observation posting from post-tool-use hook.
func (s *Service) handleObservation(w http.ResponseWriter, r *http.Request) {
var req ObservationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Reject requests from internal CLI calls (no session ID = internal callClaudeCLI)
if req.ClaudeSessionID == "" {
w.WriteHeader(http.StatusOK)
return
}
// Find session
sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sess == nil {
// Create session on-the-fly with project from request
id, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sess, _ = s.sessionStore.GetSessionByID(r.Context(), id)
}
// Queue observation
if err := s.sessionManager.QueueObservation(r.Context(), sess.ID, session.ObservationData{
ToolName: req.ToolName,
ToolInput: req.ToolInput,
ToolResponse: req.ToolResponse,
CWD: req.CWD,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.broadcastProcessingStatus()
w.WriteHeader(http.StatusOK)
}
// SubagentCompleteRequest is the request body for subagent completion.
type SubagentCompleteRequest struct {
ClaudeSessionID string `json:"claudeSessionId"`
Project string `json:"project"`
}
// handleSubagentComplete handles subagent/Task completion notifications.
// This triggers immediate processing of any queued observations from the subagent.
func (s *Service) handleSubagentComplete(w http.ResponseWriter, r *http.Request) {
var req SubagentCompleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Find session
sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID)
if err != nil || sess == nil {
// Session not found - subagent may have been in a different context
log.Debug().
Str("claudeSessionId", req.ClaudeSessionID).
Msg("Subagent complete - no active session found")
w.WriteHeader(http.StatusOK)
return
}
// Trigger immediate processing of queued observations
messages := s.sessionManager.DrainMessages(sess.ID)
if len(messages) > 0 && s.processor != nil {
log.Info().
Int64("sessionId", sess.ID).
Int("messages", len(messages)).
Msg("Processing queued observations from subagent")
for _, msg := range messages {
if msg.Type == session.MessageTypeObservation && msg.Observation != nil {
err := s.processor.ProcessObservation(
r.Context(),
sess.SDKSessionID.String,
sess.Project,
msg.Observation.ToolName,
msg.Observation.ToolInput,
msg.Observation.ToolResponse,
msg.Observation.PromptNumber,
msg.Observation.CWD,
)
if err != nil {
log.Error().Err(err).
Str("tool", msg.Observation.ToolName).
Msg("Failed to process subagent observation")
}
}
}
}
s.broadcastProcessingStatus()
w.WriteHeader(http.StatusOK)
}
// handleGetSessionByClaudeID looks up a session by Claude session ID.
func (s *Service) handleGetSessionByClaudeID(w http.ResponseWriter, r *http.Request) {
claudeSessionID := r.URL.Query().Get("claudeSessionId")
if claudeSessionID == "" {
http.Error(w, "claudeSessionId required", http.StatusBadRequest)
return
}
session, err := s.sessionStore.FindAnySDKSession(r.Context(), claudeSessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if session == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
writeJSON(w, session)
}
// SummarizeRequest is the request body for summarize requests.
type SummarizeRequest struct {
LastUserMessage string `json:"lastUserMessage"`
LastAssistantMessage string `json:"lastAssistantMessage"`
}
// handleSummarize handles summarize requests from stop hook.
func (s *Service) handleSummarize(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid session id", http.StatusBadRequest)
return
}
var req SummarizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Queue summarize request
if err := s.sessionManager.QueueSummarize(r.Context(), id, req.LastUserMessage, req.LastAssistantMessage); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.broadcastProcessingStatus()
w.WriteHeader(http.StatusOK)
}
+45 -21
View File
@@ -66,6 +66,7 @@ func testService(t *testing.T) (*Service, func()) {
cancel: cancel,
startTime: time.Now(),
retrievalStats: make(map[string]*RetrievalStats),
cachedObsCounts: make(map[string]cachedCount),
}
svc.setupRoutes()
@@ -345,11 +346,13 @@ func TestHandleGetObservations_Limit(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
// Parse as generic JSON array since the model uses custom marshaling
var observations []map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &observations)
// Parse as object with observations key (API returns wrapped response)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
observations, ok := response["observations"].([]interface{})
require.True(t, ok, "expected observations array in response")
assert.Len(t, observations, 10)
}
@@ -456,14 +459,13 @@ func TestHandleHealth_ReturnsVersion(t *testing.T) {
svc.handleHealth(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "ready", response["status"])
assert.Equal(t, "test-version-1.2.3", response["version"])
// Status may be "degraded" if embedSvc is nil in test, but version is always present
assert.Contains(t, []string{"ready", "degraded"}, response["status"])
}
func TestHandleVersion(t *testing.T) {
@@ -1135,10 +1137,13 @@ func TestHandleGetObservations_DefaultLimit(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var observations []map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &observations)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
observations, ok := response["observations"].([]interface{})
require.True(t, ok, "expected observations array in response")
// Should return default limit (100)
assert.LessOrEqual(t, len(observations), DefaultObservationsLimit)
}
@@ -1159,10 +1164,12 @@ func TestHandleGetObservations_FilterByProject(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var observations []map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &observations)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
observations, ok := response["observations"].([]interface{})
require.True(t, ok, "expected observations array in response")
assert.Len(t, observations, 2)
}
@@ -1412,10 +1419,12 @@ func TestHandleGetObservations(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var observations []map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &observations)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
observations, ok := response["observations"].([]interface{})
require.True(t, ok, "expected observations array in response")
assert.GreaterOrEqual(t, len(observations), 2)
}
@@ -2018,13 +2027,14 @@ func TestHandleHealth_NotReady(t *testing.T) {
svc.handleHealth(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "starting", response["status"])
assert.Equal(t, "initializing", response["status"])
assert.Equal(t, false, response["ready"])
}
// TestHandleContextInject_EmptyProject tests context inject with empty project.
@@ -2389,7 +2399,12 @@ func TestHandleHealthEndpoint(t *testing.T) {
svc.router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Response is valid JSON with health details
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotNil(t, response["status"])
assert.NotNil(t, response["version"])
}
// TestHandleSelfCheckEndpoint tests self-check endpoint via router.
@@ -2697,10 +2712,13 @@ func TestHandleGetObservations_EmptyResult(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
// Should return empty array, not null
var obs []interface{}
err := json.Unmarshal(rec.Body.Bytes(), &obs)
// Should return empty array within observations key, not null
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
obs, ok := response["observations"].([]interface{})
require.True(t, ok, "expected observations array in response")
assert.NotNil(t, obs)
}
@@ -2881,12 +2899,18 @@ func TestHandleHealth(t *testing.T) {
svc.router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "ready", response["status"])
// Test service has store set but no embedSvc, so status is "degraded"
assert.Contains(t, []string{"ready", "degraded"}, response["status"])
assert.NotNil(t, response["version"])
assert.NotNil(t, response["uptime_seconds"])
assert.NotNil(t, response["active_sessions"])
assert.NotNil(t, response["db_status"])
assert.NotNil(t, response["embedding_status"])
assert.NotNil(t, response["ready"])
}
// TestHandleSessionInit_ValidRequest tests session init with valid request.
+251
View File
@@ -0,0 +1,251 @@
// Package worker provides update and restart HTTP handlers.
package worker
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/rs/zerolog/log"
)
// handleUpdateCheck checks for available updates.
func (s *Service) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
info, err := s.updater.CheckForUpdate(r.Context())
if err != nil {
// Return a proper JSON response for errors instead of 500
// This allows the frontend to handle it gracefully
writeJSON(w, map[string]any{
"available": false,
"current_version": s.version,
"error": err.Error(),
"rate_limited": strings.Contains(err.Error(), "403"),
})
return
}
writeJSON(w, info)
}
// handleUpdateApply downloads and applies an available update.
func (s *Service) handleUpdateApply(w http.ResponseWriter, r *http.Request) {
// First check for update
info, err := s.updater.CheckForUpdate(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !info.Available {
writeJSON(w, map[string]any{
"success": false,
"message": "No update available",
})
return
}
// Apply update in background with tracking for graceful shutdown
s.wg.Go(func() {
if err := s.updater.ApplyUpdate(s.ctx, info); err != nil {
log.Error().Err(err).Msg("Update failed")
}
})
writeJSON(w, map[string]any{
"success": true,
"message": "Update started",
"version": info.LatestVersion,
})
}
// handleUpdateStatus returns the current update status.
func (s *Service) handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
status := s.updater.GetStatus()
writeJSON(w, status)
}
// ComponentHealth represents the health status of a single component.
type ComponentHealth struct {
Name string `json:"name"`
Status string `json:"status"` // "healthy", "degraded", "unhealthy"
Message string `json:"message,omitempty"`
}
// SelfCheckResponse contains the health status of all components.
type SelfCheckResponse struct {
Overall string `json:"overall"` // "healthy", "degraded", "unhealthy"
Version string `json:"version"`
Uptime string `json:"uptime"`
Components []ComponentHealth `json:"components"`
}
// handleSelfCheck returns the health status of all components.
func (s *Service) handleSelfCheck(w http.ResponseWriter, r *http.Request) {
components := []ComponentHealth{}
overall := "healthy"
// Check Worker Service
workerStatus := ComponentHealth{Name: "Worker Service", Status: "healthy"}
if !s.ready.Load() {
if err := s.GetInitError(); err != nil {
workerStatus.Status = "unhealthy"
workerStatus.Message = err.Error()
overall = "unhealthy"
} else {
workerStatus.Status = "degraded"
workerStatus.Message = "Initializing"
if overall == "healthy" {
overall = "degraded"
}
}
}
components = append(components, workerStatus)
// Check SQLite Database
dbStatus := ComponentHealth{Name: "SQLite Database", Status: "healthy"}
if s.store == nil {
dbStatus.Status = "unhealthy"
dbStatus.Message = "Not initialized"
overall = "unhealthy"
} else if err := s.store.Ping(); err != nil {
dbStatus.Status = "unhealthy"
dbStatus.Message = err.Error()
overall = "unhealthy"
}
components = append(components, dbStatus)
// Check Vector DB (sqlite-vec)
vectorStatus := ComponentHealth{Name: "Vector DB", Status: "healthy"}
if s.vectorClient == nil {
vectorStatus.Status = "degraded"
vectorStatus.Message = "Not configured"
if overall == "healthy" {
overall = "degraded"
}
} else if !s.vectorClient.IsConnected() {
vectorStatus.Status = "degraded"
vectorStatus.Message = "Not connected"
if overall == "healthy" {
overall = "degraded"
}
}
components = append(components, vectorStatus)
// Check SDK Processor
sdkStatus := ComponentHealth{Name: "SDK Processor", Status: "healthy"}
if s.processor == nil {
sdkStatus.Status = "degraded"
sdkStatus.Message = "Not initialized"
if overall == "healthy" {
overall = "degraded"
}
} else if !s.processor.IsAvailable() {
sdkStatus.Status = "degraded"
sdkStatus.Message = "Claude CLI not available"
if overall == "healthy" {
overall = "degraded"
}
}
components = append(components, sdkStatus)
// Check SSE Broadcaster
sseStatus := ComponentHealth{Name: "SSE Broadcaster", Status: "healthy"}
if s.sseBroadcaster == nil {
sseStatus.Status = "unhealthy"
sseStatus.Message = "Not initialized"
overall = "unhealthy"
}
components = append(components, sseStatus)
// Check Cross-Encoder Reranker
rerankerStatus := ComponentHealth{Name: "Cross-Encoder Reranker", Status: "healthy"}
if !s.config.RerankingEnabled {
rerankerStatus.Status = "degraded"
rerankerStatus.Message = "Disabled in config"
if overall == "healthy" {
overall = "degraded"
}
} else if s.reranker == nil {
rerankerStatus.Status = "degraded"
rerankerStatus.Message = "Not initialized"
if overall == "healthy" {
overall = "degraded"
}
} else {
// Verify reranker is functional using Score
_, normalizedScore, err := s.reranker.Score("test query", "test document")
if err != nil {
rerankerStatus.Status = "unhealthy"
rerankerStatus.Message = fmt.Sprintf("Score check failed: %v", err)
if overall == "healthy" {
overall = "degraded"
}
} else {
rerankerStatus.Message = fmt.Sprintf("Score check passed (%.4f)", normalizedScore)
}
}
components = append(components, rerankerStatus)
// Calculate uptime
uptime := time.Since(s.startTime).Round(time.Second).String()
writeJSON(w, SelfCheckResponse{
Overall: overall,
Version: s.version,
Uptime: uptime,
Components: components,
})
}
// handleUpdateRestart restarts the worker with the new binary (after update).
func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
status := s.updater.GetStatus()
if status.State != "done" {
http.Error(w, "no update has been applied", http.StatusBadRequest)
return
}
// Send response before restarting
writeJSON(w, map[string]any{
"success": true,
"message": "Restarting worker...",
})
// Flush the response
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Restart in background after response is sent
go func() {
if err := s.updater.Restart(); err != nil {
log.Error().Err(err).Msg("Failed to restart worker")
}
}()
}
// handleRestart restarts the worker process (general restart, not tied to update).
func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
log.Info().Msg("Manual restart requested via API")
// Send response before restarting
writeJSON(w, map[string]any{
"success": true,
"message": "Restarting worker...",
"version": s.version,
})
// Flush the response
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Restart in background after response is sent
go func() {
// Small delay to ensure response is sent
time.Sleep(100 * time.Millisecond)
if err := s.updater.Restart(); err != nil {
log.Error().Err(err).Msg("Failed to restart worker")
}
}()
}
+333
View File
@@ -0,0 +1,333 @@
// Package worker provides the main worker service for claude-mnemonic.
package worker
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// requestIDKey is the context key for request IDs.
type requestIDKey struct{}
// projectNamePattern validates project names to prevent path traversal.
var projectNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_./-]+$`)
// allowedOrigins is the whitelist of origins allowed for CORS.
// Uses exact matching to prevent bypass attacks like "evil-localhost.com".
var allowedOrigins = map[string]bool{
"http://localhost": true,
"http://localhost:3000": true,
"http://localhost:5173": true, // Vite dev server
"http://localhost:37778": true, // Dashboard UI
"http://127.0.0.1": true,
"http://127.0.0.1:3000": true,
"http://127.0.0.1:5173": true,
"http://127.0.0.1:37778": true,
}
// SecurityHeaders middleware adds essential security headers to all responses.
// These protect against common web vulnerabilities.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent clickjacking
w.Header().Set("X-Frame-Options", "DENY")
// Prevent MIME type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// Enable XSS filter
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Restrict referrer information
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy - restrict to self
w.Header().Set("Content-Security-Policy", "default-src 'self'")
// Permissions Policy - disable unnecessary features
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
// CORS: Use exact match whitelist to prevent bypass attacks
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token, Authorization, X-Request-ID")
}
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// MaxBodySize middleware limits the size of incoming request bodies.
// This prevents denial of service attacks via large payloads.
func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > maxBytes {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
// TokenAuth provides simple token-based authentication for localhost services.
// The token is generated at startup and must be provided in the X-Auth-Token header.
type TokenAuth struct {
ExemptPaths map[string]bool
token string
mu sync.RWMutex
enabled bool
}
// NewTokenAuth creates a new TokenAuth with a randomly generated token.
// If enabled is false, authentication is skipped (useful for development).
func NewTokenAuth(enabled bool) (*TokenAuth, error) {
ta := &TokenAuth{
enabled: enabled,
ExemptPaths: map[string]bool{
"/health": true,
"/api/health": true,
"/api/ready": true,
},
}
if enabled {
// Generate 32-byte random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, err
}
ta.token = hex.EncodeToString(tokenBytes)
}
return ta, nil
}
// Token returns the authentication token.
// Returns empty string if authentication is disabled.
func (ta *TokenAuth) Token() string {
ta.mu.RLock()
defer ta.mu.RUnlock()
return ta.token
}
// IsEnabled returns whether token authentication is enabled.
func (ta *TokenAuth) IsEnabled() bool {
ta.mu.RLock()
defer ta.mu.RUnlock()
return ta.enabled
}
// Middleware returns HTTP middleware that enforces token authentication.
func (ta *TokenAuth) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ta.mu.RLock()
enabled := ta.enabled
token := ta.token
exempt := ta.ExemptPaths[r.URL.Path]
ta.mu.RUnlock()
// Skip auth if disabled or path is exempt
if !enabled || exempt {
next.ServeHTTP(w, r)
return
}
// Check for token in header
providedToken := r.Header.Get("X-Auth-Token")
if providedToken == "" {
// Also check Authorization header with Bearer scheme
auth := r.Header.Get("Authorization")
if bearer, found := strings.CutPrefix(auth, "Bearer "); found {
providedToken = bearer
}
}
if providedToken != token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// ExpensiveOperationLimiter provides stricter rate limiting for expensive operations.
// It wraps the base per-client rate limiter with additional per-operation limits.
type ExpensiveOperationLimiter struct {
// Track last execution time per operation type
lastRebuild int64 // Unix timestamp
rebuildCooldown int64 // Minimum seconds between rebuilds
mu sync.Mutex
}
// NewExpensiveOperationLimiter creates a limiter for expensive operations.
func NewExpensiveOperationLimiter() *ExpensiveOperationLimiter {
return &ExpensiveOperationLimiter{
rebuildCooldown: 300, // 5 minutes between rebuilds
}
}
// CanRebuild checks if a vector rebuild operation is allowed.
// Returns false if a rebuild was triggered too recently.
func (eol *ExpensiveOperationLimiter) CanRebuild() bool {
eol.mu.Lock()
defer eol.mu.Unlock()
now := unixNow()
if now-eol.lastRebuild < eol.rebuildCooldown {
return false
}
eol.lastRebuild = now
return true
}
// unixNow returns current Unix timestamp.
// Separated for easier testing.
func unixNow() int64 {
return time.Now().Unix()
}
// RequestID middleware adds a unique request ID to each request.
// The ID is added to the context and response headers for tracing.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check for existing request ID from client
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
// Generate new request ID
idBytes := make([]byte, 8)
if _, err := rand.Read(idBytes); err == nil {
requestID = hex.EncodeToString(idBytes)
} else {
requestID = fmt.Sprintf("%d", time.Now().UnixNano())
}
}
// Add to response header
w.Header().Set("X-Request-ID", requestID)
// Add to context
ctx := context.WithValue(r.Context(), requestIDKey{}, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetRequestID retrieves the request ID from the context.
func GetRequestID(ctx context.Context) string {
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
return id
}
return ""
}
// RequireJSONContentType middleware validates that POST/PUT/PATCH requests
// have application/json Content-Type header.
func RequireJSONContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only check for methods that typically have bodies
if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" {
ct := r.Header.Get("Content-Type")
// Allow empty Content-Type for requests without body
if ct != "" && !strings.HasPrefix(ct, "application/json") {
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
return
}
}
next.ServeHTTP(w, r)
})
}
// ValidateProjectName checks if a project name is safe to use.
// Returns an error if the name contains path traversal or invalid characters.
func ValidateProjectName(project string) error {
if project == "" {
return nil // Empty is allowed (means no filter)
}
// Check for path traversal
if strings.Contains(project, "..") {
return fmt.Errorf("invalid project name: path traversal detected")
}
// Check for valid characters
if !projectNamePattern.MatchString(project) {
return fmt.Errorf("invalid project name: only alphanumeric, underscore, dash, dot, and slash allowed")
}
// Max length check
if len(project) > 500 {
return fmt.Errorf("project name too long (max 500 chars)")
}
return nil
}
// BulkOperationLimiter provides rate limiting for bulk operations.
// Prevents DoS via repeated bulk requests.
type BulkOperationLimiter struct {
lastBulkOp int64 // Unix timestamp
cooldown int64 // Minimum seconds between operations
mu sync.Mutex
}
// NewBulkOperationLimiter creates a limiter for bulk operations.
func NewBulkOperationLimiter(cooldownSeconds int64) *BulkOperationLimiter {
return &BulkOperationLimiter{
cooldown: cooldownSeconds,
}
}
// CanExecute checks if a bulk operation is allowed.
// Returns false if a bulk operation was triggered too recently.
func (bol *BulkOperationLimiter) CanExecute() bool {
bol.mu.Lock()
defer bol.mu.Unlock()
now := unixNow()
if now-bol.lastBulkOp < bol.cooldown {
return false
}
bol.lastBulkOp = now
return true
}
// TimeSinceLastOp returns seconds since the last bulk operation.
func (bol *BulkOperationLimiter) TimeSinceLastOp() int64 {
bol.mu.Lock()
defer bol.mu.Unlock()
return unixNow() - bol.lastBulkOp
}
// CooldownRemaining returns seconds remaining in the cooldown period.
// Returns 0 if no cooldown is active.
func (bol *BulkOperationLimiter) CooldownRemaining() int64 {
bol.mu.Lock()
defer bol.mu.Unlock()
remaining := bol.cooldown - (unixNow() - bol.lastBulkOp)
if remaining < 0 {
return 0
}
return remaining
}
+515
View File
@@ -0,0 +1,515 @@
package worker
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Check all security headers are set
tests := []struct {
header string
expected string
}{
{"X-Frame-Options", "DENY"},
{"X-Content-Type-Options", "nosniff"},
{"X-XSS-Protection", "1; mode=block"},
{"Referrer-Policy", "strict-origin-when-cross-origin"},
}
for _, tt := range tests {
if got := rr.Header().Get(tt.header); got != tt.expected {
t.Errorf("SecurityHeaders() %s = %q, want %q", tt.header, got, tt.expected)
}
}
}
func TestSecurityHeaders_CORS(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
name string
origin string
expectedOrigin string
expectCORS bool
}{
{
name: "localhost:37778 origin allowed",
origin: "http://localhost:37778",
expectCORS: true,
expectedOrigin: "http://localhost:37778",
},
{
name: "127.0.0.1:5173 origin allowed",
origin: "http://127.0.0.1:5173",
expectCORS: true,
expectedOrigin: "http://127.0.0.1:5173",
},
{
name: "localhost without port allowed",
origin: "http://localhost",
expectCORS: true,
expectedOrigin: "http://localhost",
},
{
name: "external origin blocked",
origin: "http://evil.com",
expectCORS: false,
},
{
name: "evil-localhost.com bypass attempt blocked",
origin: "http://evil-localhost.com",
expectCORS: false,
},
{
name: "localhost subdomain bypass attempt blocked",
origin: "http://localhost.evil.com",
expectCORS: false,
},
{
name: "unknown localhost port blocked",
origin: "http://localhost:9999",
expectCORS: false,
},
{
name: "no origin header",
origin: "",
expectCORS: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
if tt.origin != "" {
req.Header.Set("Origin", tt.origin)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
cors := rr.Header().Get("Access-Control-Allow-Origin")
if tt.expectCORS {
if cors != tt.expectedOrigin {
t.Errorf("Expected CORS origin %q, got %q", tt.expectedOrigin, cors)
}
} else {
if cors != "" {
t.Errorf("Expected no CORS header, got %q", cors)
}
}
})
}
}
func TestMaxBodySize(t *testing.T) {
maxSize := int64(100)
handler := MaxBodySize(maxSize)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
name string
contentLength int64
expectedStatus int
}{
{
name: "within limit",
contentLength: 50,
expectedStatus: http.StatusOK,
},
{
name: "at limit",
contentLength: 100,
expectedStatus: http.StatusOK,
},
{
name: "exceeds limit",
contentLength: 150,
expectedStatus: http.StatusRequestEntityTooLarge,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/test", nil)
req.ContentLength = tt.contentLength
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tt.expectedStatus {
t.Errorf("MaxBodySize() status = %d, want %d", rr.Code, tt.expectedStatus)
}
})
}
}
func TestTokenAuth(t *testing.T) {
t.Run("disabled auth allows all requests", func(t *testing.T) {
ta, err := NewTokenAuth(false)
if err != nil {
t.Fatalf("NewTokenAuth() error = %v", err)
}
handler := ta.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected OK with disabled auth, got %d", rr.Code)
}
})
t.Run("enabled auth requires token", func(t *testing.T) {
ta, err := NewTokenAuth(true)
if err != nil {
t.Fatalf("NewTokenAuth() error = %v", err)
}
handler := ta.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Without token
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected Unauthorized without token, got %d", rr.Code)
}
// With correct token in X-Auth-Token header
req = httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Auth-Token", ta.Token())
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected OK with correct token, got %d", rr.Code)
}
// With correct token in Authorization header
req = httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+ta.Token())
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected OK with Bearer token, got %d", rr.Code)
}
})
t.Run("exempt paths skip auth", func(t *testing.T) {
ta, err := NewTokenAuth(true)
if err != nil {
t.Fatalf("NewTokenAuth() error = %v", err)
}
handler := ta.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
exemptPaths := []string{"/health", "/api/health", "/api/ready"}
for _, path := range exemptPaths {
req := httptest.NewRequest("GET", path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected OK for exempt path %s, got %d", path, rr.Code)
}
}
})
}
func TestExpensiveOperationLimiter(t *testing.T) {
limiter := NewExpensiveOperationLimiter()
// First rebuild should be allowed
if !limiter.CanRebuild() {
t.Error("First rebuild should be allowed")
}
// Immediate second rebuild should be blocked
if limiter.CanRebuild() {
t.Error("Immediate second rebuild should be blocked")
}
}
func TestRequestID(t *testing.T) {
handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request ID is in context
id := GetRequestID(r.Context())
if id == "" {
t.Error("Request ID should be set in context")
}
w.WriteHeader(http.StatusOK)
}))
t.Run("generates new request ID", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Header().Get("X-Request-ID") == "" {
t.Error("X-Request-ID header should be set")
}
})
t.Run("uses existing request ID", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Request-ID", "test-id-12345")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Header().Get("X-Request-ID") != "test-id-12345" {
t.Errorf("Expected X-Request-ID to be test-id-12345, got %s", rr.Header().Get("X-Request-ID"))
}
})
}
func TestRequireJSONContentType(t *testing.T) {
handler := RequireJSONContentType(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
name string
method string
contentType string
expectedStatus int
}{
{
name: "GET request without content-type",
method: "GET",
contentType: "",
expectedStatus: http.StatusOK,
},
{
name: "POST with application/json",
method: "POST",
contentType: "application/json",
expectedStatus: http.StatusOK,
},
{
name: "POST with application/json; charset=utf-8",
method: "POST",
contentType: "application/json; charset=utf-8",
expectedStatus: http.StatusOK,
},
{
name: "POST without content-type (empty body)",
method: "POST",
contentType: "",
expectedStatus: http.StatusOK,
},
{
name: "POST with text/plain rejected",
method: "POST",
contentType: "text/plain",
expectedStatus: http.StatusUnsupportedMediaType,
},
{
name: "PUT with application/xml rejected",
method: "PUT",
contentType: "application/xml",
expectedStatus: http.StatusUnsupportedMediaType,
},
{
name: "PATCH with form-urlencoded rejected",
method: "PATCH",
contentType: "application/x-www-form-urlencoded",
expectedStatus: http.StatusUnsupportedMediaType,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/test", nil)
if tt.contentType != "" {
req.Header.Set("Content-Type", tt.contentType)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, rr.Code)
}
})
}
}
func TestValidateProjectName(t *testing.T) {
tests := []struct {
name string
project string
wantError bool
}{
{
name: "empty project allowed",
project: "",
wantError: false,
},
{
name: "simple project name",
project: "my-project",
wantError: false,
},
{
name: "project with path",
project: "org/my-project",
wantError: false,
},
{
name: "project with underscore",
project: "my_project_v2",
wantError: false,
},
{
name: "project with dot",
project: "my.project.name",
wantError: false,
},
{
name: "path traversal attack",
project: "../../../etc/passwd",
wantError: true,
},
{
name: "hidden path traversal",
project: "project/../../secret",
wantError: true,
},
{
name: "shell injection attempt",
project: "project; rm -rf /",
wantError: true,
},
{
name: "backtick injection",
project: "project`whoami`",
wantError: true,
},
{
name: "special characters",
project: "project$HOME",
wantError: true,
},
{
name: "too long project name",
project: string(make([]byte, 501)),
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateProjectName(tt.project)
if tt.wantError && err == nil {
t.Errorf("Expected error for project %q, got nil", tt.project)
}
if !tt.wantError && err != nil {
t.Errorf("Unexpected error for project %q: %v", tt.project, err)
}
})
}
}
func TestBulkOperationLimiter(t *testing.T) {
limiter := NewBulkOperationLimiter(1) // 1 second cooldown for testing
// First operation should be allowed
if !limiter.CanExecute() {
t.Error("First bulk operation should be allowed")
}
// Immediate second operation should be blocked
if limiter.CanExecute() {
t.Error("Immediate second bulk operation should be blocked")
}
// Check cooldown remaining
remaining := limiter.CooldownRemaining()
if remaining <= 0 || remaining > 1 {
t.Errorf("Expected cooldown remaining between 0-1 seconds, got %d", remaining)
}
// Check time since last op
since := limiter.TimeSinceLastOp()
if since < 0 || since > 1 {
t.Errorf("Expected time since last op between 0-1 seconds, got %d", since)
}
}
func TestSecurityHeaders_CSP(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Check CSP header is set
csp := rr.Header().Get("Content-Security-Policy")
if csp == "" {
t.Error("Content-Security-Policy header should be set")
}
if csp != "default-src 'self'" {
t.Errorf("Expected CSP to be \"default-src 'self'\", got %q", csp)
}
// Check Permissions-Policy header
pp := rr.Header().Get("Permissions-Policy")
if pp == "" {
t.Error("Permissions-Policy header should be set")
}
}
func TestSecurityHeaders_Preflight(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("OPTIONS", "/test", nil)
req.Header.Set("Origin", "http://localhost:3000")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// OPTIONS should return 204 No Content
if rr.Code != http.StatusNoContent {
t.Errorf("Expected status 204 for OPTIONS, got %d", rr.Code)
}
// CORS headers should be set for allowed origin
if rr.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" {
t.Errorf("CORS origin should be set for allowed origin")
}
if rr.Header().Get("Access-Control-Allow-Methods") == "" {
t.Error("Access-Control-Allow-Methods should be set")
}
}
+226
View File
@@ -0,0 +1,226 @@
// Package worker provides the main worker service for claude-mnemonic.
package worker
import (
"net/http"
"sync"
"time"
)
// RateLimiter implements a token bucket rate limiter.
type RateLimiter struct {
lastUpdate time.Time
rate float64
burst int
tokens float64
requests int64
rejected int64
mu sync.Mutex
}
// LastUpdateTime returns the last update time.
// Thread-safe - acquires the limiter's lock.
func (rl *RateLimiter) LastUpdateTime() time.Time {
rl.mu.Lock()
defer rl.mu.Unlock()
return rl.lastUpdate
}
// lastUpdateTimeUnlocked returns the last update time without locking.
// Caller must hold rl.mu.
func (rl *RateLimiter) lastUpdateTimeUnlocked() time.Time {
return rl.lastUpdate
}
// NewRateLimiter creates a new rate limiter.
// rate is the number of requests per second to allow.
// burst is the maximum burst of requests to allow.
func NewRateLimiter(rate float64, burst int) *RateLimiter {
return &RateLimiter{
rate: rate,
burst: burst,
tokens: float64(burst),
lastUpdate: time.Now(),
}
}
// Allow checks if a request should be allowed.
// Returns true if the request is allowed, false if rate limited.
func (rl *RateLimiter) Allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.requests++
// Calculate tokens added since last update
now := time.Now()
elapsed := now.Sub(rl.lastUpdate).Seconds()
rl.tokens += elapsed * rl.rate
if rl.tokens > float64(rl.burst) {
rl.tokens = float64(rl.burst)
}
rl.lastUpdate = now
// Check if we have a token available
if rl.tokens >= 1 {
rl.tokens--
return true
}
rl.rejected++
return false
}
// Stats returns rate limiter statistics.
func (rl *RateLimiter) Stats() map[string]any {
rl.mu.Lock()
defer rl.mu.Unlock()
return map[string]any{
"rate": rl.rate,
"burst": rl.burst,
"current_tokens": rl.tokens,
"total_requests": rl.requests,
"rejected": rl.rejected,
"rejection_rate": float64(rl.rejected) / max(float64(rl.requests), 1),
}
}
// RateLimitMiddleware creates middleware that applies rate limiting.
// Uses a shared rate limiter for all requests.
func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// PerClientRateLimiter implements per-client rate limiting.
type PerClientRateLimiter struct {
lastCleanup time.Time
clients map[string]*RateLimiter
rate float64
burst int
cleanupInterval time.Duration
maxIdleTime time.Duration
mu sync.Mutex
}
// NewPerClientRateLimiter creates a new per-client rate limiter.
func NewPerClientRateLimiter(rate float64, burst int) *PerClientRateLimiter {
return &PerClientRateLimiter{
rate: rate,
burst: burst,
clients: make(map[string]*RateLimiter),
cleanupInterval: 5 * time.Minute,
maxIdleTime: 10 * time.Minute,
lastCleanup: time.Now(),
}
}
// getLimiter returns a rate limiter for the given client key.
func (pcrl *PerClientRateLimiter) getLimiter(key string) *RateLimiter {
pcrl.mu.Lock()
defer pcrl.mu.Unlock()
// Periodic cleanup of idle clients
if time.Since(pcrl.lastCleanup) > pcrl.cleanupInterval {
pcrl.cleanupLocked()
}
limiter, exists := pcrl.clients[key]
if !exists {
limiter = NewRateLimiter(pcrl.rate, pcrl.burst)
pcrl.clients[key] = limiter
}
return limiter
}
// cleanupLocked removes idle limiters. Must be called with lock held.
// Uses consistent lock ordering: always acquire limiter.mu while holding pcrl.mu.
// This is safe because the limiter.mu critical section is brief (just reading lastUpdate).
func (pcrl *PerClientRateLimiter) cleanupLocked() {
now := time.Now()
keysToDelete := make([]string, 0)
// Check each limiter while holding pcrl.mu
// We briefly acquire limiter.mu but the critical section is minimal
for key, limiter := range pcrl.clients {
limiter.mu.Lock()
lastUpdate := limiter.lastUpdateTimeUnlocked()
limiter.mu.Unlock()
if now.Sub(lastUpdate) > pcrl.maxIdleTime {
keysToDelete = append(keysToDelete, key)
}
}
// Delete collected keys
for _, key := range keysToDelete {
delete(pcrl.clients, key)
}
pcrl.lastCleanup = now
}
// Allow checks if a request from the given client should be allowed.
func (pcrl *PerClientRateLimiter) Allow(clientKey string) bool {
return pcrl.getLimiter(clientKey).Allow()
}
// Stats returns aggregate statistics.
// Uses two-phase approach to avoid nested lock acquisition.
func (pcrl *PerClientRateLimiter) Stats() map[string]any {
// Phase 1: Collect limiters under pcrl.mu
pcrl.mu.Lock()
rate := pcrl.rate
burst := pcrl.burst
activeClients := len(pcrl.clients)
limiters := make([]*RateLimiter, 0, activeClients)
for _, limiter := range pcrl.clients {
limiters = append(limiters, limiter)
}
pcrl.mu.Unlock()
// Phase 2: Collect stats from each limiter (only acquiring limiter.mu, not pcrl.mu)
var totalRequests, totalRejected int64
for _, limiter := range limiters {
limiter.mu.Lock()
totalRequests += limiter.requests
totalRejected += limiter.rejected
limiter.mu.Unlock()
}
return map[string]any{
"rate": rate,
"burst": burst,
"active_clients": activeClients,
"total_requests": totalRequests,
"total_rejected": totalRejected,
}
}
// PerClientRateLimitMiddleware creates middleware that applies per-client rate limiting.
// Uses X-Forwarded-For or RemoteAddr to identify clients.
func PerClientRateLimitMiddleware(limiter *PerClientRateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get client identifier (prefer X-Real-IP from RealIP middleware)
clientKey := r.RemoteAddr
if xff := r.Header.Get("X-Real-IP"); xff != "" {
clientKey = xff
}
if !limiter.Allow(clientKey) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
+161
View File
@@ -0,0 +1,161 @@
// Package sdk provides write-time observation deduplication via vector similarity.
package sdk
import (
"context"
"fmt"
"strings"
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// DeduplicationResult represents the outcome of a vector similarity dedup check.
type DeduplicationResult struct {
ExistingID int64
Similarity float64
Action string // "insert", "merge"
}
// checkVectorDeduplication checks if a similar observation already exists using vector similarity.
// Returns a result indicating whether to insert or merge, or an error.
// On any failure, returns Action="insert" so the caller always proceeds with storage.
func (p *Processor) checkVectorDeduplication(ctx context.Context, obs *models.ParsedObservation, project string) *DeduplicationResult {
cfg := config.Get()
if !cfg.DeduplicationEnabled {
return &DeduplicationResult{Action: "insert"}
}
if p.vectorClient == nil {
return &DeduplicationResult{Action: "insert"}
}
// Build search text from observation fields
searchText := buildObservationSearchText(obs)
if searchText == "" {
return &DeduplicationResult{Action: "insert"}
}
// Query vector DB for similar observations in the same project
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, project)
results, err := p.vectorClient.Query(ctx, searchText, 3, where)
if err != nil {
log.Debug().Err(err).Msg("Vector search failed during dedup check")
return &DeduplicationResult{Action: "insert"}
}
// Check results for high similarity
for _, r := range results {
if r.Similarity >= cfg.DeduplicationThreshold {
obsID := extractObservationIDFromVectorDoc(r)
if obsID > 0 {
return &DeduplicationResult{
ExistingID: obsID,
Similarity: r.Similarity,
Action: "merge",
}
}
}
}
return &DeduplicationResult{Action: "insert"}
}
// buildObservationSearchText creates searchable text from a parsed observation.
func buildObservationSearchText(obs *models.ParsedObservation) string {
var parts []string
if obs.Title != "" {
parts = append(parts, obs.Title)
}
if obs.Subtitle != "" {
parts = append(parts, obs.Subtitle)
}
if obs.Narrative != "" {
parts = append(parts, obs.Narrative)
}
text := strings.Join(parts, " ")
if len(text) > 2000 {
text = text[:2000]
}
return text
}
// extractObservationIDFromVectorDoc extracts the SQLite observation ID from a vector query result.
func extractObservationIDFromVectorDoc(r sqlitevec.QueryResult) int64 {
// Prefer the sqlite_id metadata field (set during vector sync)
if sqliteID, ok := r.Metadata["sqlite_id"].(float64); ok && sqliteID > 0 {
return int64(sqliteID)
}
if sqliteID, ok := r.Metadata["sqlite_id"].(int64); ok && sqliteID > 0 {
return sqliteID
}
// Fallback: parse from doc_id format "obs_{id}_composite" or "obs_{id}_narrative"
if !strings.HasPrefix(r.ID, "obs_") {
return 0
}
parts := strings.SplitN(r.ID[4:], "_", 2)
if len(parts) == 0 {
return 0
}
var id int64
fmt.Sscanf(parts[0], "%d", &id)
return id
}
// mergeObservation updates an existing observation with new information from a duplicate.
// It appends new facts, updates the narrative if the new one is longer,
// and bumps the importance score to reflect reconfirmation.
func (p *Processor) mergeObservation(ctx context.Context, existingID int64, newObs *models.ParsedObservation) error {
existing, err := p.observationStore.GetObservationByID(ctx, existingID)
if err != nil {
return fmt.Errorf("fetch existing observation %d: %w", existingID, err)
}
if existing == nil {
return fmt.Errorf("observation %d not found", existingID)
}
update := &gorm.ObservationUpdate{}
changed := false
// Merge facts: append new facts not already present
if len(newObs.Facts) > 0 {
existingFactSet := make(map[string]struct{}, len(existing.Facts))
for _, f := range existing.Facts {
existingFactSet[f] = struct{}{}
}
mergedFacts := make([]string, len(existing.Facts))
copy(mergedFacts, existing.Facts)
for _, f := range newObs.Facts {
if _, exists := existingFactSet[f]; !exists {
mergedFacts = append(mergedFacts, f)
changed = true
}
}
if changed {
update.Facts = &mergedFacts
}
}
// Update narrative if the new one is longer/more detailed
if len(newObs.Narrative) > len(existing.Narrative.String) {
update.Narrative = &newObs.Narrative
changed = true
}
if !changed {
// Nothing new to merge, but still count it as a confirmed observation
log.Debug().Int64("id", existingID).Msg("Dedup merge: no new content, skipping update")
return nil
}
_, err = p.observationStore.UpdateObservation(ctx, existingID, update)
if err != nil {
return fmt.Errorf("update observation %d: %w", existingID, err)
}
return nil
}
+143
View File
@@ -0,0 +1,143 @@
package sdk
import (
"testing"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
func TestBuildObservationSearchText(t *testing.T) {
tests := []struct {
name string
obs *models.ParsedObservation
expected string
}{
{
name: "empty observation",
obs: &models.ParsedObservation{},
expected: "",
},
{
name: "title only",
obs: &models.ParsedObservation{
Title: "Fix database connection",
},
expected: "Fix database connection",
},
{
name: "all fields",
obs: &models.ParsedObservation{
Title: "Fix database connection",
Subtitle: "Connection pooling issue",
Narrative: "The database connection pool was exhausted due to leaked connections.",
},
expected: "Fix database connection Connection pooling issue The database connection pool was exhausted due to leaked connections.",
},
{
name: "truncates long text",
obs: &models.ParsedObservation{
Narrative: string(make([]byte, 3000)),
},
expected: string(make([]byte, 2000)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildObservationSearchText(tt.obs)
if result != tt.expected {
t.Errorf("got %q, want %q", result, tt.expected)
}
})
}
}
func TestExtractObservationIDFromVectorDoc(t *testing.T) {
tests := []struct {
name string
result sqlitevec.QueryResult
expected int64
}{
{
name: "from sqlite_id metadata (float64)",
result: sqlitevec.QueryResult{
ID: "obs_42_narrative",
Metadata: map[string]any{"sqlite_id": float64(42)},
},
expected: 42,
},
{
name: "from sqlite_id metadata (int64)",
result: sqlitevec.QueryResult{
ID: "obs_42_narrative",
Metadata: map[string]any{"sqlite_id": int64(42)},
},
expected: 42,
},
{
name: "fallback to doc_id parsing",
result: sqlitevec.QueryResult{
ID: "obs_99_composite",
Metadata: map[string]any{},
},
expected: 99,
},
{
name: "non-observation doc_id",
result: sqlitevec.QueryResult{
ID: "summary_5_text",
Metadata: map[string]any{},
},
expected: 0,
},
{
name: "zero sqlite_id falls back to doc_id",
result: sqlitevec.QueryResult{
ID: "obs_123_narrative",
Metadata: map[string]any{"sqlite_id": float64(0)},
},
expected: 123,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractObservationIDFromVectorDoc(tt.result)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
func TestCheckVectorDeduplication_NilClient(t *testing.T) {
p := &Processor{
// No vectorClient set
}
obs := &models.ParsedObservation{
Title: "Test observation",
Narrative: "Some narrative text",
}
result := p.checkVectorDeduplication(nil, obs, "test-project")
if result.Action != "insert" {
t.Errorf("expected Action='insert' when vectorClient is nil, got %q", result.Action)
}
}
func TestCheckVectorDeduplication_EmptySearchText(t *testing.T) {
p := &Processor{
// vectorClient would be set but obs is empty
}
obs := &models.ParsedObservation{
// All empty fields
}
result := p.checkVectorDeduplication(nil, obs, "test-project")
if result.Action != "insert" {
t.Errorf("expected Action='insert' for empty observation, got %q", result.Action)
}
}
+496 -50
View File
@@ -4,24 +4,229 @@ package sdk
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
json "github.com/goccy/go-json"
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/lukaszraczylo/claude-mnemonic/pkg/similarity"
"github.com/rs/zerolog/log"
)
// CircuitBreaker implements a simple circuit breaker pattern for CLI calls.
type CircuitBreaker struct {
failures int64 // Current failure count
lastFailure int64 // Unix timestamp of last failure
threshold int64 // Number of failures before opening
resetTimeout int64 // Seconds to wait before trying again
state int32 // 0=closed, 1=open, 2=half-open
}
const (
circuitClosed int32 = 0
circuitOpen int32 = 1
circuitHalfOpen int32 = 2
)
// NewCircuitBreaker creates a new circuit breaker.
func NewCircuitBreaker(threshold int64, resetTimeout int64) *CircuitBreaker {
return &CircuitBreaker{
threshold: threshold,
resetTimeout: resetTimeout,
}
}
// Allow checks if a request should be allowed through.
func (cb *CircuitBreaker) Allow() bool {
state := atomic.LoadInt32(&cb.state)
if state == circuitClosed {
return true
}
if state == circuitOpen {
// Check if reset timeout has passed
lastFail := atomic.LoadInt64(&cb.lastFailure)
if time.Now().Unix()-lastFail > cb.resetTimeout {
// Transition to half-open
atomic.CompareAndSwapInt32(&cb.state, circuitOpen, circuitHalfOpen)
return true
}
return false
}
// Half-open: allow one request through
return true
}
// RecordSuccess records a successful call.
func (cb *CircuitBreaker) RecordSuccess() {
atomic.StoreInt64(&cb.failures, 0)
atomic.StoreInt32(&cb.state, circuitClosed)
}
// RecordFailure records a failed call.
func (cb *CircuitBreaker) RecordFailure() {
failures := atomic.AddInt64(&cb.failures, 1)
atomic.StoreInt64(&cb.lastFailure, time.Now().Unix())
if failures >= cb.threshold {
atomic.StoreInt32(&cb.state, circuitOpen)
log.Warn().Int64("failures", failures).Msg("Circuit breaker opened - Claude CLI calls temporarily disabled")
}
}
// State returns the current state as a string.
func (cb *CircuitBreaker) State() string {
switch atomic.LoadInt32(&cb.state) {
case circuitOpen:
return "open"
case circuitHalfOpen:
return "half-open"
default:
return "closed"
}
}
// CircuitBreakerMetrics contains metrics about the circuit breaker state.
type CircuitBreakerMetrics struct {
State string `json:"state"`
Failures int64 `json:"failures"`
Threshold int64 `json:"threshold"`
ResetTimeoutSecs int64 `json:"reset_timeout_secs"`
LastFailureUnix int64 `json:"last_failure_unix,omitempty"`
SecondsUntilReset int64 `json:"seconds_until_reset,omitempty"`
}
// Metrics returns the current metrics of the circuit breaker.
func (cb *CircuitBreaker) Metrics() CircuitBreakerMetrics {
failures := atomic.LoadInt64(&cb.failures)
lastFail := atomic.LoadInt64(&cb.lastFailure)
state := cb.State()
metrics := CircuitBreakerMetrics{
State: state,
Failures: failures,
Threshold: cb.threshold,
ResetTimeoutSecs: cb.resetTimeout,
}
if lastFail > 0 {
metrics.LastFailureUnix = lastFail
if state == "open" {
remaining := cb.resetTimeout - (time.Now().Unix() - lastFail)
if remaining > 0 {
metrics.SecondsUntilReset = remaining
}
}
}
return metrics
}
// RequestDeduplicator tracks recent requests to prevent duplicates.
type RequestDeduplicator struct {
seen map[string]int64 // hash -> timestamp
mu sync.RWMutex
ttlSecs int64
maxSize int
}
// NewRequestDeduplicator creates a new deduplicator.
func NewRequestDeduplicator(ttlSecs int64, maxSize int) *RequestDeduplicator {
return &RequestDeduplicator{
seen: make(map[string]int64),
ttlSecs: ttlSecs,
maxSize: maxSize,
}
}
// IsDuplicate checks if a request hash was seen recently.
func (d *RequestDeduplicator) IsDuplicate(hash string) bool {
now := time.Now().Unix()
d.mu.RLock()
ts, exists := d.seen[hash]
d.mu.RUnlock()
if exists && now-ts < d.ttlSecs {
return true
}
return false
}
// Record marks a request hash as seen.
func (d *RequestDeduplicator) Record(hash string) {
now := time.Now().Unix()
d.mu.Lock()
defer d.mu.Unlock()
// Evict old entries if at capacity
if len(d.seen) >= d.maxSize {
threshold := now - d.ttlSecs
for k, ts := range d.seen {
if ts < threshold {
delete(d.seen, k)
}
}
}
d.seen[hash] = now
}
// hashRequest creates a hash of a request for deduplication.
func hashRequest(toolName, input, output string) string {
h := sha256.New()
h.Write([]byte(toolName))
h.Write([]byte(input))
h.Write([]byte(output[:min(len(output), 1000)])) // Only hash first 1000 chars of output
return hex.EncodeToString(h.Sum(nil))[:16] // Short hash is sufficient
}
// maxStdoutBytes is the maximum number of bytes to capture from CLI stdout.
const maxStdoutBytes = 1 * 1024 * 1024 // 1 MiB
// maxStderrBytes is the maximum number of bytes to capture from CLI stderr.
const maxStderrBytes = 64 * 1024 // 64 KiB
// limitedWriter wraps a bytes.Buffer and silently discards writes beyond a maximum size.
type limitedWriter struct {
buf bytes.Buffer
max int
}
// Write implements io.Writer. It writes up to the remaining capacity and silently discards the rest.
func (lw *limitedWriter) Write(p []byte) (int, error) {
remaining := lw.max - lw.buf.Len()
if remaining <= 0 {
return len(p), nil // Silently discard
}
if len(p) > remaining {
p = p[:remaining]
}
lw.buf.Write(p)
return len(p), nil
}
// String returns the buffered content as a string.
func (lw *limitedWriter) String() string {
return lw.buf.String()
}
// BroadcastFunc is a callback for broadcasting events to SSE clients.
type BroadcastFunc func(event map[string]interface{})
type BroadcastFunc func(event map[string]any)
// SyncObservationFunc is a callback for syncing observations to vector DB.
type SyncObservationFunc func(obs *models.Observation)
@@ -29,16 +234,27 @@ type SyncObservationFunc func(obs *models.Observation)
// SyncSummaryFunc is a callback for syncing summaries to vector DB.
type SyncSummaryFunc func(summary *models.SessionSummary)
// MaxVectorSyncWorkers is the maximum number of concurrent vector sync operations.
// This prevents unbounded goroutine spawning during high-volume observation ingestion.
const MaxVectorSyncWorkers = 8
// Processor handles SDK agent processing of observations and summaries using Claude Code CLI.
// Field order optimized for memory alignment (fieldalignment).
type Processor struct {
observationStore *gorm.ObservationStore
summaryStore *gorm.SummaryStore
vectorClient *sqlitevec.Client
broadcastFunc BroadcastFunc
syncObservationFunc SyncObservationFunc
syncSummaryFunc SyncSummaryFunc
circuitBreaker *CircuitBreaker
deduplicator *RequestDeduplicator
vectorSyncChan chan *models.Observation
vectorSyncDone chan struct{}
sem chan struct{}
claudePath string
model string
vectorSyncWg sync.WaitGroup
}
// SetBroadcastFunc sets the broadcast callback for SSE events.
@@ -56,8 +272,13 @@ func (p *Processor) SetSyncSummaryFunc(fn SyncSummaryFunc) {
p.syncSummaryFunc = fn
}
// SetVectorClient sets the vector client for write-time deduplication.
func (p *Processor) SetVectorClient(client *sqlitevec.Client) {
p.vectorClient = client
}
// broadcast sends an event via the broadcast callback if set.
func (p *Processor) broadcast(event map[string]interface{}) {
func (p *Processor) broadcast(event map[string]any) {
if p.broadcastFunc != nil {
p.broadcastFunc(event)
}
@@ -93,9 +314,65 @@ func NewProcessor(observationStore *gorm.ObservationStore, summaryStore *gorm.Su
observationStore: observationStore,
summaryStore: summaryStore,
sem: make(chan struct{}, MaxConcurrentCLICalls),
circuitBreaker: NewCircuitBreaker(5, 60), // Open after 5 failures, reset after 60s
deduplicator: NewRequestDeduplicator(300, 1000), // 5-minute TTL, 1000 max entries
vectorSyncChan: make(chan *models.Observation, MaxVectorSyncWorkers*2), // Buffered channel
vectorSyncDone: make(chan struct{}),
}, nil
}
// StartVectorSyncWorkers starts the bounded worker pool for vector sync operations.
// Call this after setting the sync function via SetSyncObservationFunc.
func (p *Processor) StartVectorSyncWorkers() {
for i := 0; i < MaxVectorSyncWorkers; i++ {
p.vectorSyncWg.Add(1)
go p.vectorSyncWorker()
}
log.Info().Int("workers", MaxVectorSyncWorkers).Msg("Vector sync worker pool started")
}
// StopVectorSyncWorkers gracefully stops the worker pool.
func (p *Processor) StopVectorSyncWorkers() {
close(p.vectorSyncDone)
p.vectorSyncWg.Wait()
log.Info().Msg("Vector sync worker pool stopped")
}
// vectorSyncWorker is a worker goroutine that processes vector sync requests.
func (p *Processor) vectorSyncWorker() {
defer p.vectorSyncWg.Done()
for {
select {
case <-p.vectorSyncDone:
// Drain remaining items before exiting
for {
select {
case obs := <-p.vectorSyncChan:
if p.syncObservationFunc != nil {
p.syncObservationFunc(obs)
}
default:
return
}
}
case obs := <-p.vectorSyncChan:
if p.syncObservationFunc != nil {
p.syncObservationFunc(obs)
}
}
}
}
// CircuitBreakerState returns the current state of the circuit breaker.
func (p *Processor) CircuitBreakerState() string {
return p.circuitBreaker.State()
}
// CircuitBreakerMetrics returns detailed metrics about the circuit breaker.
func (p *Processor) CircuitBreakerMetrics() CircuitBreakerMetrics {
return p.circuitBreaker.Metrics()
}
// IsAvailable checks if the Claude CLI is available for processing.
func (p *Processor) IsAvailable() bool {
_, err := os.Stat(p.claudePath)
@@ -103,7 +380,7 @@ func (p *Processor) IsAvailable() bool {
}
// ProcessObservation processes a single tool observation and extracts insights.
func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, project string, toolName string, toolInput, toolResponse interface{}, promptNumber int, cwd string) error {
func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, project string, toolName string, toolInput, toolResponse any, promptNumber int, cwd string) error {
// Skip certain tools that aren't worth processing
if shouldSkipTool(toolName) {
log.Info().Str("tool", toolName).Msg("Skipping tool (not interesting for memory)")
@@ -120,11 +397,23 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec
return nil
}
// Check for duplicate request within TTL window
reqHash := hashRequest(toolName, inputStr, outputStr)
if p.deduplicator.IsDuplicate(reqHash) {
log.Debug().Str("tool", toolName).Msg("Skipping duplicate request (dedup)")
return nil
}
// Check circuit breaker before making CLI call
if !p.circuitBreaker.Allow() {
log.Warn().Str("tool", toolName).Msg("Circuit breaker open - skipping CLI call")
return fmt.Errorf("circuit breaker open")
}
log.Info().Str("tool", toolName).Msg("Processing tool execution with Claude CLI")
// Note: Removed the "file already has observations" check
// Each tool execution can produce unique insights even for the same file
// Similarity-based deduplication will handle true duplicates
// Record this request to prevent duplicates
p.deduplicator.Record(reqHash)
// Build the prompt
exec := ToolExecution{
@@ -146,9 +435,11 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec
// Call Claude Code CLI
response, err := p.callClaudeCLI(ctx, prompt)
if err != nil {
p.circuitBreaker.RecordFailure()
log.Error().Err(err).Str("tool", toolName).Msg("Failed to call Claude CLI for observation")
return err
}
p.circuitBreaker.RecordSuccess()
// Parse observations from response
observations := ParseObservations(response, sdkSessionID)
@@ -175,16 +466,34 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec
// Convert to stored observation for similarity check
storedObs := obs.ToStoredObservation()
// Check if this observation is too similar to existing ones
// Check if this observation is too similar to existing ones (text-based Jaccard)
if existingObs != nil && similarity.IsSimilarToAny(storedObs, existingObs, similarityThreshold) {
log.Debug().
Str("type", string(obs.Type)).
Str("title", obs.Title).
Msg("Skipping observation - too similar to existing")
Msg("Skipping observation - too similar to existing (text)")
skippedCount++
continue
}
// Check vector similarity for high-confidence dedup with merge
dedupResult := p.checkVectorDeduplication(ctx, obs, project)
if dedupResult.Action == "merge" {
log.Info().
Int64("existing_id", dedupResult.ExistingID).
Float64("similarity", dedupResult.Similarity).
Str("title", obs.Title).
Msg("Merging duplicate observation (vector dedup)")
if err := p.mergeObservation(ctx, dedupResult.ExistingID, obs); err != nil {
log.Warn().Err(err).Int64("existing_id", dedupResult.ExistingID).
Msg("Merge failed, inserting as new observation")
// Fall through to normal insert
} else {
skippedCount++
continue
}
}
id, createdAtEpoch, err := p.observationStore.StoreObservation(ctx, sdkSessionID, project, obs, promptNumber, 0)
if err != nil {
log.Error().Err(err).Msg("Failed to store observation")
@@ -199,16 +508,26 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec
Int("trackedFiles", len(obs.FileMtimes)).
Msg("Observation stored")
// Sync to vector DB if callback is set
if p.syncObservationFunc != nil {
// Sync to vector DB via bounded worker pool (non-blocking to reduce latency)
if p.syncObservationFunc != nil && p.vectorSyncChan != nil {
fullObs := models.NewObservation(sdkSessionID, project, obs, promptNumber, 0)
fullObs.ID = id
fullObs.CreatedAtEpoch = createdAtEpoch
p.syncObservationFunc(fullObs)
// Non-blocking send to worker pool - drops if channel is full
select {
case p.vectorSyncChan <- fullObs:
// Sent to worker pool
default:
// Channel full, fall back to direct sync in goroutine (bounded by channel buffer)
log.Debug().Int64("obs_id", id).Msg("Vector sync channel full, using fallback goroutine")
go func(obsToSync *models.Observation) {
p.syncObservationFunc(obsToSync)
}(fullObs)
}
}
// Broadcast new observation event for dashboard refresh
p.broadcast(map[string]interface{}{
p.broadcast(map[string]any{
"type": "observation",
"action": "created",
"id": id,
@@ -310,7 +629,7 @@ func (p *Processor) ProcessSummary(ctx context.Context, sessionDBID int64, sdkSe
}
// Broadcast new summary event for dashboard refresh
p.broadcast(map[string]interface{}{
p.broadcast(map[string]any{
"type": "summary",
"action": "created",
"id": id,
@@ -320,8 +639,31 @@ func (p *Processor) ProcessSummary(ctx context.Context, sessionDBID int64, sdkSe
return nil
}
// MaxPromptSize is the maximum size of a prompt that can be passed to the Claude CLI.
// This prevents resource exhaustion from extremely large prompts.
const MaxPromptSize = 100 * 1024 // 100KB
// sanitizePrompt removes null bytes and control characters from a prompt.
// Keeps newlines, tabs, and carriage returns as they're valid in prompts.
func sanitizePrompt(s string) string {
return strings.Map(func(r rune) rune {
// Keep printable ASCII, extended Unicode, and common whitespace
if r >= 32 || r == '\n' || r == '\t' || r == '\r' {
return r
}
// Remove null bytes and other control characters
return -1
}, s)
}
// callClaudeCLI calls the Claude Code CLI with the given prompt.
func (p *Processor) callClaudeCLI(ctx context.Context, prompt string) (string, error) {
// Validate and sanitize prompt
if len(prompt) > MaxPromptSize {
return "", fmt.Errorf("prompt exceeds maximum size of %d bytes", MaxPromptSize)
}
prompt = sanitizePrompt(prompt)
// Build the full prompt with system instructions
fullPrompt := systemPrompt + "\n\n" + prompt
@@ -354,13 +696,19 @@ func (p *Processor) callClaudeCLI(ctx context.Context, prompt string) (string, e
// (hooks are triggered based on working directory)
cmd.Dir = "/tmp"
// Disable any plugin hooks by setting an env var that our hooks can check
// Disable any plugin hooks by setting an env var that our hooks can check.
// NOTE: This env var is NOT reliably propagated to hook subprocesses because
// Claude Code constructs its own environment for hooks. The server-side check
// in handleSessionInit (handlers_sessions.go) provides the reliable guard by
// detecting the system prompt signature in the prompt text. This env var is
// kept as a best-effort defense-in-depth measure for cases where it does work.
cmd.Env = append(os.Environ(), "CLAUDE_MNEMONIC_INTERNAL=1")
// Capture output
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// Capture output with size limits to prevent unbounded memory usage
stdout := &limitedWriter{max: maxStdoutBytes}
stderr := &limitedWriter{max: maxStderrBytes}
cmd.Stdout = stdout
cmd.Stderr = stderr
// Run command
err := cmd.Run()
@@ -418,8 +766,11 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
return true
}
// Skip if output indicates an error or empty result
// Pre-compute lowercase strings once to avoid repeated allocations
lowerOutput := strings.ToLower(outputStr)
lowerInput := strings.ToLower(inputStr)
// Skip if output indicates an error or empty result
trivialOutputs := []string{
"no matches found",
"file not found",
@@ -443,13 +794,13 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
// Skip reading config files that rarely contain project-specific insights
boringFiles := []string{
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
"go.sum", "Cargo.lock", "Gemfile.lock", "poetry.lock",
"go.sum", "cargo.lock", "gemfile.lock", "poetry.lock",
".gitignore", ".dockerignore", ".eslintignore",
"tsconfig.json", "jsconfig.json", "vite.config",
"tailwind.config", "postcss.config",
}
for _, boring := range boringFiles {
if strings.Contains(inputStr, boring) {
if strings.Contains(lowerInput, boring) {
return true
}
}
@@ -461,14 +812,14 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
}
case "Bash":
// Skip simple status commands
// Skip simple status commands (use pre-computed lowerInput)
boringCommands := []string{
"git status", "git diff", "git log", "git branch",
"ls ", "pwd", "echo ", "cat ", "which ", "type ",
"npm list", "npm outdated", "npm audit",
}
for _, boring := range boringCommands {
if strings.Contains(strings.ToLower(inputStr), boring) {
if strings.Contains(lowerInput, boring) {
return true
}
}
@@ -478,7 +829,7 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
}
// toJSONString converts an interface to a JSON string.
func toJSONString(v interface{}) string {
func toJSONString(v any) string {
if v == nil {
return ""
}
@@ -492,38 +843,132 @@ func toJSONString(v interface{}) string {
return string(b)
}
// safeResolvePath resolves a path relative to cwd and validates it doesn't escape the cwd directory.
// Returns the resolved absolute path and true if valid, or empty string and false if path traversal detected.
// This function is a security sanitizer for path traversal attacks.
func safeResolvePath(path, cwd string) (string, bool) {
// Clean the input path to normalize any .. or . components
cleanPath := filepath.Clean(path)
// Reject paths that explicitly contain parent directory traversal after cleaning
if strings.Contains(cleanPath, "..") {
return "", false
}
if filepath.IsAbs(cleanPath) {
// For absolute paths, verify they're within cwd if cwd is specified
if cwd != "" {
cleanCwd := filepath.Clean(cwd)
if !strings.HasPrefix(cleanPath, cleanCwd+string(filepath.Separator)) && cleanPath != cleanCwd {
return "", false
}
}
return cleanPath, true
}
if cwd == "" {
return cleanPath, true
}
// Clean the cwd first
cleanCwd := filepath.Clean(cwd)
// Join and clean the path
absPath := filepath.Join(cleanCwd, cleanPath)
// Use filepath.Rel to verify the path is actually within cwd
// If Rel returns a path starting with "..", it escapes the base
rel, err := filepath.Rel(cleanCwd, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return "", false
}
return absPath, true
}
// captureFileMtimes captures current modification times for tracked files.
// Returns a map of absolute file paths to their mtime in epoch milliseconds.
// For large file lists (>10 files), uses parallel stat calls for better performance.
func captureFileMtimes(filesRead, filesModified []string, cwd string) map[string]int64 {
mtimes := make(map[string]int64)
// Combine all unique file paths
allPaths := make(map[string]struct{}, len(filesRead)+len(filesModified))
for _, path := range filesRead {
allPaths[path] = struct{}{}
}
for _, path := range filesModified {
allPaths[path] = struct{}{}
}
// Helper to get mtime for a file path
getMtime := func(path string) (int64, bool) {
// Resolve relative paths against cwd
absPath := path
if !filepath.IsAbs(path) && cwd != "" {
absPath = filepath.Join(cwd, path)
// For small lists, use sequential processing (goroutine overhead not worth it)
if len(allPaths) <= 10 {
return captureFileMtimesSequential(allPaths, cwd)
}
// For larger lists, parallelize with bounded concurrency
return captureFileMtimesParallel(allPaths, cwd)
}
// captureFileMtimesSequential captures mtimes sequentially (efficient for small lists).
func captureFileMtimesSequential(paths map[string]struct{}, cwd string) map[string]int64 {
mtimes := make(map[string]int64, len(paths))
for path := range paths {
absPath, ok := safeResolvePath(path, cwd)
if !ok {
// Skip paths that attempt directory traversal
continue
}
info, err := os.Stat(absPath)
if err != nil {
return 0, false
}
return info.ModTime().UnixMilli(), true
}
// Capture mtimes for all read files
for _, path := range filesRead {
if mtime, ok := getMtime(path); ok {
mtimes[path] = mtime
if err == nil {
mtimes[path] = info.ModTime().UnixMilli()
}
}
// Capture mtimes for all modified files
for _, path := range filesModified {
if mtime, ok := getMtime(path); ok {
mtimes[path] = mtime
}
return mtimes
}
// captureFileMtimesParallel captures mtimes in parallel with bounded concurrency.
func captureFileMtimesParallel(paths map[string]struct{}, cwd string) map[string]int64 {
type mtimeResult struct {
path string
mtime int64
}
results := make(chan mtimeResult, len(paths))
sem := make(chan struct{}, 8) // Limit to 8 concurrent stat calls
var wg sync.WaitGroup
for path := range paths {
wg.Add(1)
go func(p string) {
defer wg.Done()
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
absPath, ok := safeResolvePath(p, cwd)
if !ok {
// Skip paths that attempt directory traversal
return
}
info, err := os.Stat(absPath)
if err == nil {
results <- mtimeResult{path: p, mtime: info.ModTime().UnixMilli()}
}
}(path)
}
// Close results channel when all goroutines complete
go func() {
wg.Wait()
close(results)
}()
// Collect results
mtimes := make(map[string]int64, len(paths))
for res := range results {
mtimes[res.path] = res.mtime
}
return mtimes
@@ -538,12 +983,13 @@ func GetFileMtimes(paths []string, cwd string) map[string]int64 {
// GetFileContent reads file content for verification purposes.
// Returns content and ok status.
func GetFileContent(path, cwd string) (string, bool) {
absPath := path
if !filepath.IsAbs(path) && cwd != "" {
absPath = filepath.Join(cwd, path)
absPath, ok := safeResolvePath(path, cwd)
if !ok {
// Reject paths that attempt directory traversal
return "", false
}
content, err := os.ReadFile(absPath) // #nosec G304 -- intentional file read for verification
content, err := os.ReadFile(absPath) // #nosec G304 -- path validated by safeResolvePath
if err != nil {
return "", false
}
+867
View File
@@ -1,9 +1,12 @@
package sdk
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
@@ -974,3 +977,867 @@ func TestSyncSummaryFuncType(t *testing.T) {
}
assert.NotNil(t, fn)
}
// TestSanitizePrompt tests prompt sanitization for CLI safety.
func TestSanitizePrompt(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "normal text",
input: "Hello, world!",
expected: "Hello, world!",
},
{
name: "text with newlines",
input: "Line 1\nLine 2\nLine 3",
expected: "Line 1\nLine 2\nLine 3",
},
{
name: "text with tabs",
input: "Key:\tValue",
expected: "Key:\tValue",
},
{
name: "text with carriage return",
input: "Line 1\r\nLine 2",
expected: "Line 1\r\nLine 2",
},
{
name: "text with null bytes",
input: "Hello\x00World",
expected: "HelloWorld",
},
{
name: "text with control characters",
input: "Hello\x01\x02\x03World",
expected: "HelloWorld",
},
{
name: "text with bell character",
input: "Hello\x07World",
expected: "HelloWorld",
},
{
name: "text with backspace",
input: "Hello\x08World",
expected: "HelloWorld",
},
{
name: "text with form feed",
input: "Hello\x0cWorld",
expected: "HelloWorld",
},
{
name: "text with escape",
input: "Hello\x1bWorld",
expected: "HelloWorld",
},
{
name: "unicode text",
input: "Hello 世界 🌍",
expected: "Hello 世界 🌍",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "only control characters",
input: "\x00\x01\x02\x03",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizePrompt(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// TestMaxPromptSize tests that MaxPromptSize is reasonable.
func TestMaxPromptSize(t *testing.T) {
assert.Equal(t, 100*1024, MaxPromptSize)
}
// BenchmarkSanitizePrompt benchmarks the sanitize function.
func BenchmarkSanitizePrompt(b *testing.B) {
prompt := "Analyze the following code:\n```go\nfunc main() {\n\tfmt.Println(\"Hello, World!\")\n}\n```\n\nPlease identify any issues."
b.ResetTimer()
for i := 0; i < b.N; i++ {
sanitizePrompt(prompt)
}
}
// BenchmarkSanitizePromptWithControlChars benchmarks sanitization with control characters.
func BenchmarkSanitizePromptWithControlChars(b *testing.B) {
prompt := "Hello\x00World\x01Test\x02Data\x03End"
b.ResetTimer()
for i := 0; i < b.N; i++ {
sanitizePrompt(prompt)
}
}
// TestSafeResolvePath tests the path traversal protection.
func TestSafeResolvePath(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
tests := []struct {
name string
path string
cwd string
wantPath string
wantOk bool
}{
{
name: "simple relative path",
path: "file.txt",
cwd: tmpDir,
wantOk: true,
wantPath: filepath.Join(tmpDir, "file.txt"),
},
{
name: "nested relative path",
path: "subdir/file.txt",
cwd: tmpDir,
wantOk: true,
wantPath: filepath.Join(tmpDir, "subdir", "file.txt"),
},
{
name: "path traversal with ..",
path: "../etc/passwd",
cwd: tmpDir,
wantOk: false,
},
{
name: "path traversal with multiple ..",
path: "../../etc/passwd",
cwd: tmpDir,
wantOk: false,
},
{
name: "path traversal hidden in middle",
path: "subdir/../../../etc/passwd",
cwd: tmpDir,
wantOk: false,
},
{
name: "just parent directory",
path: "..",
cwd: tmpDir,
wantOk: false,
},
{
name: "absolute path without cwd",
path: "/some/absolute/path",
cwd: "",
wantOk: true,
wantPath: "/some/absolute/path",
},
{
name: "relative path without cwd",
path: "relative/path",
cwd: "",
wantOk: true,
wantPath: "relative/path",
},
{
name: "current directory reference",
path: "./file.txt",
cwd: tmpDir,
wantOk: true,
wantPath: filepath.Join(tmpDir, "file.txt"),
},
{
name: "absolute path outside cwd",
path: "/etc/passwd",
cwd: tmpDir,
wantOk: false,
},
{
name: "absolute path inside cwd",
path: filepath.Join(tmpDir, "inside.txt"),
cwd: tmpDir,
wantOk: true,
wantPath: filepath.Join(tmpDir, "inside.txt"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPath, gotOk := safeResolvePath(tt.path, tt.cwd)
assert.Equal(t, tt.wantOk, gotOk, "ok status mismatch")
if tt.wantPath != "" && gotOk {
assert.Equal(t, tt.wantPath, gotPath, "path mismatch")
}
})
}
}
// =============================================================================
// TESTS FOR CircuitBreaker
// =============================================================================
func TestNewCircuitBreaker(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
assert.NotNil(t, cb)
assert.Equal(t, int64(5), cb.threshold)
assert.Equal(t, int64(60), cb.resetTimeout)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Allow_Closed(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
// Closed state should allow requests
assert.True(t, cb.Allow())
assert.True(t, cb.Allow())
}
func TestCircuitBreaker_Allow_Open(t *testing.T) {
cb := NewCircuitBreaker(2, 60) // Low threshold for testing
// Record enough failures to open the circuit
cb.RecordFailure()
cb.RecordFailure()
// Open state should block requests
assert.False(t, cb.Allow())
assert.Equal(t, "open", cb.State())
}
func TestCircuitBreaker_RecordSuccess(t *testing.T) {
cb := NewCircuitBreaker(2, 60)
// Record a failure
cb.RecordFailure()
assert.Equal(t, int64(1), cb.Metrics().Failures)
// Record success resets failures
cb.RecordSuccess()
assert.Equal(t, int64(0), cb.Metrics().Failures)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_RecordFailure_OpensCircuit(t *testing.T) {
cb := NewCircuitBreaker(3, 60)
// Record failures below threshold
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
// Third failure should open circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
}
func TestCircuitBreaker_State(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
// Initially closed
assert.Equal(t, "closed", cb.State())
// After failure, open
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
// After success, closed
cb.RecordSuccess()
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Metrics(t *testing.T) {
cb := NewCircuitBreaker(5, 120)
metrics := cb.Metrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
assert.Equal(t, int64(0), metrics.LastFailureUnix)
// After failure
cb.RecordFailure()
metrics = cb.Metrics()
assert.Equal(t, int64(1), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
}
func TestCircuitBreaker_Metrics_OpenWithReset(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
metrics := cb.Metrics()
assert.Equal(t, "open", metrics.State)
assert.Greater(t, metrics.SecondsUntilReset, int64(0))
assert.LessOrEqual(t, metrics.SecondsUntilReset, int64(60))
}
// =============================================================================
// TESTS FOR RequestDeduplicator
// =============================================================================
func TestNewRequestDeduplicator(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
assert.NotNil(t, d)
assert.NotNil(t, d.seen)
assert.Equal(t, int64(300), d.ttlSecs)
assert.Equal(t, 1000, d.maxSize)
}
func TestRequestDeduplicator_IsDuplicate_NotSeen(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
// New hash is not a duplicate
assert.False(t, d.IsDuplicate("newhash"))
}
func TestRequestDeduplicator_IsDuplicate_AfterRecord(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
hash := "testhash"
// Record the hash
d.Record(hash)
// Now it should be a duplicate
assert.True(t, d.IsDuplicate(hash))
}
func TestRequestDeduplicator_Record(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
hash := "recordtest"
d.Record(hash)
// Check it was recorded
d.mu.RLock()
_, exists := d.seen[hash]
d.mu.RUnlock()
assert.True(t, exists)
}
func TestRequestDeduplicator_Record_Eviction(t *testing.T) {
// Small maxSize for testing eviction
d := NewRequestDeduplicator(0, 2) // TTL of 0 means everything is "old"
// Record until capacity
d.Record("hash1")
d.Record("hash2")
// Recording a third should trigger eviction (since TTL is 0)
d.Record("hash3")
// Should have cleaned up old entries
d.mu.RLock()
size := len(d.seen)
d.mu.RUnlock()
// Size should be limited (eviction occurred)
assert.LessOrEqual(t, size, 3)
}
func TestHashRequest(t *testing.T) {
tests := []struct {
name string
toolName string
input string
output string
compareWith []string
wantLen int
wantSame bool
}{
{
name: "basic hash",
toolName: "Read",
input: "file.txt",
output: "content",
wantLen: 16,
},
{
name: "consistent hashing",
toolName: "Edit",
input: "same input",
output: "same output",
wantLen: 16,
},
{
name: "long output truncation",
toolName: "Bash",
input: "command",
output: string(make([]byte, 5000)), // Very long output
wantLen: 16,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash := hashRequest(tt.toolName, tt.input, tt.output)
assert.Len(t, hash, tt.wantLen)
// Same inputs should produce same hash
hash2 := hashRequest(tt.toolName, tt.input, tt.output)
assert.Equal(t, hash, hash2)
})
}
}
func TestHashRequest_DifferentInputs(t *testing.T) {
// Different inputs should produce different hashes
hash1 := hashRequest("Read", "file1.txt", "content1")
hash2 := hashRequest("Read", "file2.txt", "content2")
assert.NotEqual(t, hash1, hash2)
}
func TestHashRequest_OutputTruncation(t *testing.T) {
// Hash should be the same for outputs that differ only after 1000 chars
longOutput1 := string(make([]byte, 1500))
longOutput2 := longOutput1[:1000] + "different suffix here"
hash1 := hashRequest("Read", "input", longOutput1)
hash2 := hashRequest("Read", "input", longOutput2)
// Since we only hash first 1000 chars, these should be the same
assert.Equal(t, hash1, hash2)
}
// =============================================================================
// TESTS FOR Processor methods
// =============================================================================
func TestProcessor_CircuitBreakerState(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(2, 60),
}
// Initially closed
assert.Equal(t, "closed", p.CircuitBreakerState())
// After enough failures, open
p.circuitBreaker.RecordFailure()
p.circuitBreaker.RecordFailure()
assert.Equal(t, "open", p.CircuitBreakerState())
// After success, closed
p.circuitBreaker.RecordSuccess()
assert.Equal(t, "closed", p.CircuitBreakerState())
}
func TestProcessor_CircuitBreakerMetrics(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 120),
}
metrics := p.CircuitBreakerMetrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
// Record a failure and check metrics update
p.circuitBreaker.RecordFailure()
metrics = p.CircuitBreakerMetrics()
assert.Equal(t, int64(1), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
}
// =============================================================================
// TESTS FOR Vector Sync Workers
// =============================================================================
func TestProcessor_StartAndStopVectorSyncWorkers(t *testing.T) {
var syncedObservations []*models.Observation
var mu sync.Mutex
p := &Processor{
vectorSyncChan: make(chan *models.Observation, MaxVectorSyncWorkers*2),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: func(obs *models.Observation) {
mu.Lock()
syncedObservations = append(syncedObservations, obs)
mu.Unlock()
},
}
// Start workers
p.StartVectorSyncWorkers()
// Send some observations
obs1 := &models.Observation{SDKSessionID: "test1"}
obs2 := &models.Observation{SDKSessionID: "test2"}
p.vectorSyncChan <- obs1
p.vectorSyncChan <- obs2
// Give workers time to process
time.Sleep(50 * time.Millisecond)
// Stop workers
p.StopVectorSyncWorkers()
// Verify observations were synced
mu.Lock()
assert.Len(t, syncedObservations, 2)
mu.Unlock()
}
func TestProcessor_VectorSyncWorker_DrainOnShutdown(t *testing.T) {
var syncedCount int
var mu sync.Mutex
p := &Processor{
vectorSyncChan: make(chan *models.Observation, 10),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: func(obs *models.Observation) {
mu.Lock()
syncedCount++
mu.Unlock()
},
}
// Queue observations before starting workers
for i := 0; i < 5; i++ {
p.vectorSyncChan <- &models.Observation{SDKSessionID: "pre-queued"}
}
// Start workers
p.StartVectorSyncWorkers()
// Stop immediately - workers should drain the queue
p.StopVectorSyncWorkers()
// All pre-queued items should have been processed
mu.Lock()
assert.Equal(t, 5, syncedCount)
mu.Unlock()
}
func TestProcessor_VectorSyncWorker_NilSyncFunc(t *testing.T) {
p := &Processor{
vectorSyncChan: make(chan *models.Observation, 10),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: nil, // No sync function set
}
// Start workers
p.StartVectorSyncWorkers()
// Send observation - should not panic even with nil sync func
p.vectorSyncChan <- &models.Observation{SDKSessionID: "test"}
// Give it time to process
time.Sleep(50 * time.Millisecond)
// Stop workers - should not panic
p.StopVectorSyncWorkers()
}
// =============================================================================
// TESTS FOR CircuitBreaker Additional Behaviors
// =============================================================================
func TestCircuitBreaker_Allow_OpenBlocksRequests(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
// Open the circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
// All requests should be blocked
assert.False(t, cb.Allow())
assert.False(t, cb.Allow())
assert.False(t, cb.Allow())
}
func TestCircuitBreaker_MultipleFailures(t *testing.T) {
cb := NewCircuitBreaker(3, 60) // Higher threshold
// Record failures below threshold
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
assert.Equal(t, int64(1), cb.Metrics().Failures)
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
assert.Equal(t, int64(2), cb.Metrics().Failures)
// Third failure opens circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
assert.Equal(t, int64(3), cb.Metrics().Failures)
}
func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
// Record some failures
cb.RecordFailure()
cb.RecordFailure()
assert.Equal(t, int64(2), cb.Metrics().Failures)
// Success resets failures
cb.RecordSuccess()
assert.Equal(t, int64(0), cb.Metrics().Failures)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Metrics_Comprehensive(t *testing.T) {
cb := NewCircuitBreaker(5, 120)
// Initial state
metrics := cb.Metrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
assert.Equal(t, int64(0), metrics.LastFailureUnix)
assert.Equal(t, int64(0), metrics.SecondsUntilReset)
// After failures that open circuit
for i := 0; i < 5; i++ {
cb.RecordFailure()
}
metrics = cb.Metrics()
assert.Equal(t, "open", metrics.State)
assert.Equal(t, int64(5), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
assert.Greater(t, metrics.SecondsUntilReset, int64(0))
}
// =============================================================================
// TESTS FOR MaxVectorSyncWorkers constant
// =============================================================================
func TestMaxVectorSyncWorkers(t *testing.T) {
assert.Equal(t, 8, MaxVectorSyncWorkers)
}
// =============================================================================
// ADDITIONAL EDGE CASE TESTS
// =============================================================================
func TestRequestDeduplicator_IsDuplicate_ExpiredEntry(t *testing.T) {
if testing.Short() {
t.Skip("Skipping time-dependent test in short mode")
}
// Use a 1-second TTL with enough margin
d := NewRequestDeduplicator(1, 100)
hash := "expiretest"
d.Record(hash)
// Initially duplicate
assert.True(t, d.IsDuplicate(hash))
// Wait for TTL to expire (2.5 seconds to ensure crossing second boundaries)
time.Sleep(2500 * time.Millisecond)
// Should no longer be considered duplicate
assert.False(t, d.IsDuplicate(hash))
}
// =============================================================================
// TESTS FOR ProcessObservation Early Returns
// =============================================================================
func TestProcessObservation_SkipTool(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// TodoWrite should be skipped
err := p.ProcessObservation(ctx, "session-1", "project-1", "TodoWrite",
map[string]string{"content": "test"}, "success", 1, "/test/cwd")
assert.NoError(t, err)
// Glob should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "Glob",
map[string]string{"pattern": "*.go"}, []string{"main.go", "test.go"}, 1, "/test/cwd")
assert.NoError(t, err)
// AskUserQuestion should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "AskUserQuestion",
"question", "answer", 1, "/test/cwd")
assert.NoError(t, err)
}
func TestProcessObservation_SkipTrivial(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Short output should be skipped
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read",
map[string]string{"file_path": "/test.go"}, "short", 1, "/test/cwd")
assert.NoError(t, err)
// "No matches found" should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "Grep",
map[string]string{"pattern": "test"}, "No matches found in the repository", 1, "/test/cwd")
assert.NoError(t, err)
}
func TestProcessObservation_SkipDuplicate(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 4),
claudePath: "/nonexistent/path", // Will fail at CLI call stage
}
ctx := context.Background()
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
// First call should try to process (will fail because claudePath doesn't exist)
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
// Expect error because claudePath doesn't exist
assert.Error(t, err)
// Second call with same input should be skipped as duplicate
err = p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.NoError(t, err) // No error because it was skipped as duplicate
}
func TestProcessObservation_CircuitBreakerOpen(t *testing.T) {
cb := NewCircuitBreaker(1, 60) // Threshold of 1
cb.RecordFailure() // Open the circuit breaker
p := &Processor{
circuitBreaker: cb,
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "circuit breaker open")
}
func TestProcessObservation_ContextCancel(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 1), // Small semaphore
claudePath: "/fake/claude",
}
// Fill the semaphore
p.sem <- struct{}{}
// Create a cancelled context
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
}
// =============================================================================
// TESTS FOR ProcessSummary Early Returns
// =============================================================================
func TestProcessSummary_SkipEmptyRequest(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Empty request should be skipped (sessionDBID, sdkSessionID, project, userPrompt, lastUserMsg, lastAssistantMsg)
err := p.ProcessSummary(ctx, 1, "session-1", "project-1", "", "", "")
assert.NoError(t, err)
}
func TestProcessSummary_CircuitBreakerOpen(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
cb.RecordFailure() // Open the circuit breaker
p := &Processor{
circuitBreaker: cb,
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 4),
claudePath: "/nonexistent/path",
}
ctx := context.Background()
// Meaningful assistant message (> 200 chars, contains code discussion)
assistantMsg := `I've updated the handler.go file to fix the authentication bug.
The function validateToken() was not checking token expiry correctly.
I've added a check for the exp claim and implemented proper error handling.
The changes have been tested and the build passes successfully.
Here's the implementation details and code review.`
// Valid request but circuit breaker is open
err := p.ProcessSummary(ctx, 1, "session-1", "project-1",
"Implement authentication", "User message", assistantMsg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "claude CLI failed")
}
// =============================================================================
// TESTS FOR callClaudeCLI Error Paths
// =============================================================================
func TestCallClaudeCLI_PromptTooLarge(t *testing.T) {
p := &Processor{
claudePath: "/fake/claude",
}
ctx := context.Background()
// Create a prompt that exceeds MaxPromptSize
largePrompt := string(make([]byte, MaxPromptSize+1))
_, err := p.callClaudeCLI(ctx, largePrompt)
assert.Error(t, err)
assert.Contains(t, err.Error(), "prompt exceeds maximum size")
}
func TestCallClaudeCLI_BinaryNotFound(t *testing.T) {
p := &Processor{
claudePath: "/nonexistent/path/to/claude",
}
ctx := context.Background()
_, err := p.callClaudeCLI(ctx, "test prompt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "claude CLI failed")
}
+9 -3
View File
@@ -49,6 +49,10 @@ func BuildObservationPrompt(exec ToolExecution) string {
inputJSON, _ := json.MarshalIndent(toolInput, " ", " ")
outputJSON, _ := json.MarshalIndent(toolOutput, " ", " ")
// Strip system XML artifacts from tool data before building prompt
cleanInput := StripSystemXML(string(inputJSON))
cleanOutput := StripSystemXML(string(outputJSON))
timestamp := time.UnixMilli(exec.CreatedAtEpoch).Format(time.RFC3339)
var sb strings.Builder
@@ -58,8 +62,8 @@ func BuildObservationPrompt(exec ToolExecution) string {
if exec.CWD != "" {
sb.WriteString(fmt.Sprintf(" <working_directory>%s</working_directory>\n", exec.CWD))
}
sb.WriteString(fmt.Sprintf(" <parameters>%s</parameters>\n", truncate(string(inputJSON), 3000)))
sb.WriteString(fmt.Sprintf(" <outcome>%s</outcome>\n", truncate(string(outputJSON), 5000)))
sb.WriteString(fmt.Sprintf(" <parameters>%s</parameters>\n", truncate(cleanInput, 3000)))
sb.WriteString(fmt.Sprintf(" <outcome>%s</outcome>\n", truncate(cleanOutput, 5000)))
sb.WriteString("</observed_from_primary_session>")
return sb.String()
@@ -84,8 +88,10 @@ func BuildSummaryPrompt(req SummaryRequest) string {
sb.WriteString("Write progress notes of what was done, what was learned, and what's next. This is a checkpoint to capture progress so far. The session is ongoing - you may receive more requests and tool executions after this summary. Write \"next_steps\" as the current trajectory of work (what's actively being worked on or coming up next), not as post-session future work. Always write at least a minimal summary explaining current progress, even if work is still in early stages, so that users see a summary output tied to each request.\n\n")
if req.LastAssistantMessage != "" {
// Strip system XML artifacts from captured transcript content
cleanMsg := StripSystemXML(req.LastAssistantMessage)
sb.WriteString("Claude's Full Response to User:\n")
sb.WriteString(truncate(req.LastAssistantMessage, 4000))
sb.WriteString(truncate(cleanMsg, 4000))
sb.WriteString("\n\n")
}
+8
View File
@@ -0,0 +1,8 @@
// Package sdk provides SDK agent integration for claude-mnemonic.
package sdk
import "github.com/lukaszraczylo/claude-mnemonic/pkg/sanitize"
// StripSystemXML removes known Claude Code internal XML blocks from text.
// Delegates to the shared sanitize package.
var StripSystemXML = sanitize.StripSystemXML
+704 -443
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -75,6 +75,7 @@ type Manager struct {
onDeleted func(int64)
cancel context.CancelFunc
ProcessNotify chan struct{}
wg sync.WaitGroup
mu sync.RWMutex
}
@@ -89,12 +90,14 @@ func NewManager(sessionStore *gorm.SessionStore) *Manager {
ProcessNotify: make(chan struct{}, 1),
}
// Start background cleanup goroutine
m.wg.Add(1)
go m.cleanupLoop()
return m
}
// cleanupLoop periodically removes stale sessions.
func (m *Manager) cleanupLoop() {
defer m.wg.Done()
ticker := time.NewTicker(CleanupInterval)
defer ticker.Stop()
@@ -350,6 +353,7 @@ func (m *Manager) DeleteSession(sessionDBID int64) {
func (m *Manager) ShutdownAll(ctx context.Context) {
// Stop cleanup goroutine
m.cancel()
m.wg.Wait()
m.mu.Lock()
sessionIDs := make([]int64, 0, len(m.sessions))
+732
View File
@@ -693,3 +693,735 @@ func TestToolInputResponse(t *testing.T) {
})
}
}
// =============================================================================
// TESTS FOR NewManager AND CLEANUP
// =============================================================================
// TestNewManager tests the NewManager function.
func TestNewManager(t *testing.T) {
t.Parallel()
// Test with nil session store (valid for testing)
manager := NewManager(nil)
assert.NotNil(t, manager)
assert.NotNil(t, manager.sessions)
assert.NotNil(t, manager.ProcessNotify)
assert.NotNil(t, manager.ctx)
assert.NotNil(t, manager.cancel)
assert.Equal(t, 0, manager.GetActiveSessionCount())
// Clean up - cancel context to stop cleanup goroutine
manager.cancel()
}
// TestNewManager_CleanupGoroutineStops tests that cleanup goroutine stops on cancel.
func TestNewManager_CleanupGoroutineStops(t *testing.T) {
t.Parallel()
manager := NewManager(nil)
// Give goroutine time to start
time.Sleep(10 * time.Millisecond)
// Cancel should stop the cleanup goroutine
manager.cancel()
// Context should be done
select {
case <-manager.ctx.Done():
// Expected
case <-time.After(100 * time.Millisecond):
t.Error("Context should be done after cancel")
}
}
// TestCleanupStaleSessions_NoSessions tests cleanup with no sessions.
func TestCleanupStaleSessions_NoSessions(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Should not panic with empty sessions
manager.cleanupStaleSessions()
assert.Equal(t, 0, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_FreshSession tests that fresh sessions are not cleaned.
func TestCleanupStaleSessions_FreshSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a fresh session
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now(), // Fresh
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should still exist (not stale)
assert.Equal(t, 1, manager.GetActiveSessionCount())
sessionCancel()
}
// TestCleanupStaleSessions_StaleSession tests that stale sessions are cleaned.
func TestCleanupStaleSessions_StaleSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should be deleted
assert.Equal(t, 0, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_StaleWithPending tests stale sessions with pending messages are not cleaned.
func TestCleanupStaleSessions_StaleWithPending(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session with pending messages
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{{Type: MessageTypeObservation}},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should NOT be deleted (has pending messages)
assert.Equal(t, 1, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_StaleWithActiveGenerator tests stale sessions with active generator are not cleaned.
func TestCleanupStaleSessions_StaleWithActiveGenerator(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session with active generator
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
session := &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
session.generatorActive.Store(true)
manager.sessions[1] = session
manager.cleanupStaleSessions()
// Session should NOT be deleted (generator is active)
assert.Equal(t, 1, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_MixedSessions tests cleanup with mixed fresh and stale sessions.
func TestCleanupStaleSessions_MixedSessions(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Fresh session
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now(),
pendingMessages: []PendingMessage{},
ctx: ctx1,
cancel: cancel1,
}
// Stale session (should be deleted)
ctx2, cancel2 := context.WithCancel(context.Background())
manager.sessions[2] = &ActiveSession{
SessionDBID: 2,
StartTime: time.Now().Add(-SessionTimeout - time.Minute),
pendingMessages: []PendingMessage{},
ctx: ctx2,
cancel: cancel2,
}
// Stale session with pending (should NOT be deleted)
ctx3, cancel3 := context.WithCancel(context.Background())
defer cancel3()
manager.sessions[3] = &ActiveSession{
SessionDBID: 3,
StartTime: time.Now().Add(-SessionTimeout - time.Minute),
pendingMessages: []PendingMessage{{Type: MessageTypeObservation}},
ctx: ctx3,
cancel: cancel3,
}
manager.cleanupStaleSessions()
// Should have 2 sessions left (1 fresh, 1 stale with pending)
assert.Equal(t, 2, manager.GetActiveSessionCount())
// Verify which sessions remain
manager.mu.RLock()
_, has1 := manager.sessions[1]
_, has2 := manager.sessions[2]
_, has3 := manager.sessions[3]
manager.mu.RUnlock()
assert.True(t, has1, "Fresh session should remain")
assert.False(t, has2, "Stale session should be deleted")
assert.True(t, has3, "Stale session with pending should remain")
}
// TestCleanupLoop_ExitsOnCancel tests that cleanup loop exits when context is cancelled.
func TestCleanupLoop_ExitsOnCancel(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
manager.ctx = ctx
manager.cancel = cancel
// Start cleanup loop in goroutine
done := make(chan struct{})
manager.wg.Add(1)
go func() {
manager.cleanupLoop()
close(done)
}()
// Cancel immediately
cancel()
// Should exit quickly
select {
case <-done:
// Success - loop exited
case <-time.After(100 * time.Millisecond):
t.Error("Cleanup loop should exit when context is cancelled")
}
}
// =============================================================================
// TESTS FOR InitializeSession (without DB)
// =============================================================================
// TestInitializeSession_AlreadyActive tests reusing an already active session.
func TestInitializeSession_AlreadyActive(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add an active session
existingSession := &ActiveSession{
SessionDBID: 42,
ClaudeSessionID: "claude-existing",
Project: "test-project",
UserPrompt: "original prompt",
LastPromptNumber: 1,
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
}
manager.sessions[42] = existingSession
// Initialize same session - should reuse
session, err := manager.InitializeSession(context.Background(), 42, "new prompt", 5)
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Same(t, existingSession, session)
assert.Equal(t, "new prompt", session.UserPrompt)
assert.Equal(t, 5, session.LastPromptNumber)
}
// TestInitializeSession_AlreadyActive_EmptyPrompt tests reusing session with empty prompt.
func TestInitializeSession_AlreadyActive_EmptyPrompt(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add an active session
existingSession := &ActiveSession{
SessionDBID: 42,
UserPrompt: "original prompt",
LastPromptNumber: 1,
}
manager.sessions[42] = existingSession
// Initialize with empty prompt - should NOT update
session, err := manager.InitializeSession(context.Background(), 42, "", 0)
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Equal(t, "original prompt", session.UserPrompt) // Unchanged
assert.Equal(t, 1, session.LastPromptNumber) // Unchanged
}
// TestInitializeSession_NoStore tests initialization without session store.
func TestInitializeSession_NoStore(t *testing.T) {
t.Parallel()
manager := &Manager{
sessionStore: nil, // No store
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Should fail gracefully with nil store (panic recovery not expected)
// This tests the guard against nil sessionStore
defer func() {
if r := recover(); r != nil {
_ = r // Expected panic when calling nil store - intentionally ignored
}
}()
_, _ = manager.InitializeSession(context.Background(), 999, "prompt", 1)
}
// TestInitializeSession_CallbackTriggered tests that created callback is triggered.
func TestInitializeSession_CallbackTriggered(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
var calledWithID int64
manager.SetOnSessionCreated(func(id int64) {
calledWithID = id
})
// Add session directly (simulating what would happen after DB fetch)
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
session := &ActiveSession{
SessionDBID: 100,
ClaudeSessionID: "test",
Project: "project",
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.mu.Lock()
manager.sessions[100] = session
onCreated := manager.onCreated
manager.mu.Unlock()
// Trigger callback
if onCreated != nil {
onCreated(100)
}
assert.Equal(t, int64(100), calledWithID)
}
// =============================================================================
// TESTS FOR QueueObservation AND QueueSummarize (without DB)
// =============================================================================
// TestQueueObservation_ToExistingSession tests queuing to an existing session.
func TestQueueObservation_ToExistingSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue observation
err := manager.QueueObservation(context.Background(), 1, ObservationData{
ToolName: "Read",
ToolInput: map[string]string{"path": "/test"},
ToolResponse: "content",
PromptNumber: 1,
CWD: "/project",
})
assert.NoError(t, err)
assert.Equal(t, 1, manager.GetTotalQueueDepth())
// Verify message
messages := manager.DrainMessages(1)
assert.Len(t, messages, 1)
assert.Equal(t, MessageTypeObservation, messages[0].Type)
assert.Equal(t, "Read", messages[0].Observation.ToolName)
assert.Equal(t, "/project", messages[0].Observation.CWD)
}
// TestQueueObservation_NotifiesSession tests that notification is sent to session.
func TestQueueObservation_NotifiesSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue observation
err := manager.QueueObservation(context.Background(), 1, ObservationData{ToolName: "Test"})
assert.NoError(t, err)
// Should receive notification on session channel
select {
case <-session.notify:
// Success
default:
t.Error("Session should receive notification")
}
// Should receive notification on process channel
select {
case <-manager.ProcessNotify:
// Success
default:
t.Error("Manager ProcessNotify should receive notification")
}
}
// TestQueueSummarize_ToExistingSession tests queuing summarize to an existing session.
func TestQueueSummarize_ToExistingSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue summarize
err := manager.QueueSummarize(context.Background(), 1, "User asked question", "Assistant answered")
assert.NoError(t, err)
assert.Equal(t, 1, manager.GetTotalQueueDepth())
// Verify message
messages := manager.DrainMessages(1)
assert.Len(t, messages, 1)
assert.Equal(t, MessageTypeSummarize, messages[0].Type)
assert.Equal(t, "User asked question", messages[0].Summarize.LastUserMessage)
assert.Equal(t, "Assistant answered", messages[0].Summarize.LastAssistantMessage)
}
// TestQueueSummarize_NotifiesSession tests that notification is sent to session.
func TestQueueSummarize_NotifiesSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue summarize
err := manager.QueueSummarize(context.Background(), 1, "user", "assistant")
assert.NoError(t, err)
// Should receive notification on session channel
select {
case <-session.notify:
// Success
default:
t.Error("Session should receive notification")
}
// Should receive notification on process channel
select {
case <-manager.ProcessNotify:
// Success
default:
t.Error("Manager ProcessNotify should receive notification")
}
}
// TestQueueOperations_MultipleMessages tests queuing multiple messages.
func TestQueueOperations_MultipleMessages(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue multiple messages
for i := 0; i < 10; i++ {
if i%2 == 0 {
err := manager.QueueObservation(context.Background(), 1, ObservationData{
ToolName: "Tool" + string(rune('A'+i)),
})
assert.NoError(t, err)
} else {
err := manager.QueueSummarize(context.Background(), 1, "user", "assistant")
assert.NoError(t, err)
}
}
assert.Equal(t, 10, manager.GetTotalQueueDepth())
// Drain and verify
messages := manager.DrainMessages(1)
assert.Len(t, messages, 10)
}
// TestQueueOperations_NonBlockingNotification tests non-blocking notification behavior.
func TestQueueOperations_NonBlockingNotification(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with full notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
// Fill the notify channel
session.notify <- struct{}{}
manager.sessions[1] = session
// Fill ProcessNotify channel
manager.ProcessNotify <- struct{}{}
// Queue should NOT block even with full channels
done := make(chan bool)
go func() {
err := manager.QueueObservation(context.Background(), 1, ObservationData{ToolName: "Test"})
assert.NoError(t, err)
done <- true
}()
select {
case <-done:
// Success - didn't block
case <-time.After(100 * time.Millisecond):
t.Error("Queue operation should not block even with full notification channels")
}
}
// TestConcurrentQueueAndCleanup tests concurrent queue operations and cleanup.
func TestConcurrentQueueAndCleanup(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add multiple sessions
for i := int64(1); i <= 5; i++ {
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[i] = &ActiveSession{
SessionDBID: i,
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
ctx: sessionCtx,
cancel: sessionCancel,
}
}
var wg sync.WaitGroup
// Concurrent queue operations
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sessionID := int64((idx % 5) + 1)
if idx%2 == 0 {
_ = manager.QueueObservation(context.Background(), sessionID, ObservationData{ToolName: "Test"})
} else {
_ = manager.QueueSummarize(context.Background(), sessionID, "user", "assistant")
}
}(i)
}
// Concurrent cleanup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
manager.cleanupStaleSessions()
}()
}
// Concurrent reads
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = manager.GetActiveSessionCount()
_ = manager.GetTotalQueueDepth()
_ = manager.IsAnySessionProcessing()
_ = manager.GetAllSessions()
}()
}
wg.Wait()
// Should have all sessions (none are stale)
assert.Equal(t, 5, manager.GetActiveSessionCount())
// Should have 50 messages total
assert.Equal(t, 50, manager.GetTotalQueueDepth())
}
+1 -1
View File
@@ -212,7 +212,7 @@ func (b *Broadcaster) HandleSSE(w http.ResponseWriter, r *http.Request) {
defer b.RemoveClient(client)
// Send initial connection message
fmt.Fprintf(w, "data: {\"type\":\"connected\",\"clientId\":\"%s\"}\n\n", client.ID)
_, _ = fmt.Fprintf(w, "data: {\"type\":\"connected\",\"clientId\":\"%s\"}\n\n", client.ID)
client.Flusher.Flush()
// Wait for client disconnect
+16 -4
View File
@@ -5,6 +5,8 @@ import (
"io/fs"
"net/http"
"strings"
"github.com/rs/zerolog/log"
)
//go:embed static/*
@@ -13,16 +15,22 @@ var staticFS embed.FS
// staticSubFS is the static subdirectory filesystem
var staticSubFS fs.FS
// staticInitErr stores any error from static filesystem initialization
var staticInitErr error
func init() {
var err error
staticSubFS, err = fs.Sub(staticFS, "static")
if err != nil {
panic("failed to create sub filesystem: " + err.Error())
staticSubFS, staticInitErr = fs.Sub(staticFS, "static")
if staticInitErr != nil {
log.Warn().Err(staticInitErr).Msg("Static filesystem initialization failed - dashboard will be unavailable")
}
}
// serveIndex serves the index.html file for the root path
func serveIndex(w http.ResponseWriter, r *http.Request) {
if staticInitErr != nil {
http.Error(w, "Dashboard unavailable: static files not initialized", http.StatusServiceUnavailable)
return
}
content, err := fs.ReadFile(staticSubFS, "index.html")
if err != nil {
http.Error(w, "Dashboard not found", http.StatusNotFound)
@@ -38,6 +46,10 @@ func serveIndex(w http.ResponseWriter, r *http.Request) {
// serveAssets serves static assets from the embedded filesystem
func serveAssets(w http.ResponseWriter, r *http.Request) {
if staticInitErr != nil {
http.Error(w, "Assets unavailable: static files not initialized", http.StatusServiceUnavailable)
return
}
// Strip the /assets/ prefix and serve the file
path := strings.TrimPrefix(r.URL.Path, "/")
+10 -10
View File
@@ -13,7 +13,7 @@
{
"name": "claude-mnemonic",
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
"version": "0.11.5",
"version": "0.11.73",
"author": {
"name": "lukaszraczylo",
"url": "https://github.com/lukaszraczylo"
@@ -23,25 +23,25 @@
"license": "MIT",
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
"releases": {
"latest": "0.11.5",
"latest": "0.11.73",
"versions": {
"0.11.5": {
"releaseDate": "2026-01-09",
"0.11.73": {
"releaseDate": "2026-03-17",
"downloads": {
"darwin-arm64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.5/claude-mnemonic_0.11.5_darwin_arm64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.73/claude-mnemonic_0.11.73_darwin_arm64.tar.gz",
"format": "tar.gz",
"sha256": "ec5b1a17de7eef54d6a9140b73655aff8a66fbd0034af72e02a0f0b7cf8c5bc0"
"sha256": "e42261ff4a660f0cd423429fcbc626f48b5241c1ddd445ae419b6f7bb0be4aa2"
},
"linux-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.5/claude-mnemonic_0.11.5_linux_amd64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.73/claude-mnemonic_0.11.73_linux_amd64.tar.gz",
"format": "tar.gz",
"sha256": "d94ed456688b8a86474834e58ae5d8188a28f461553b087cf47084da9036c086"
"sha256": "942e37617b370e709942d610c7cc44a465be8e9be1c32802f7bd480715f3d925"
},
"windows-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.5/claude-mnemonic_0.11.5_windows_amd64.zip",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.73/claude-mnemonic_0.11.73_windows_amd64.zip",
"format": "zip",
"sha256": "5f80dcf395922b6a1591625966cfbc606a0ed2685d99b8d0d0f2ef0b99095914"
"sha256": "8b50813be58d96859b2a45d138596b1bd5eb04ad53983016a8bb829706a31358"
}
}
}
+11 -1
View File
@@ -2,6 +2,7 @@
package hooks
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -9,6 +10,7 @@ import (
"io"
"os"
"path/filepath"
"time"
)
// HookResponse is the response sent back to Claude Code.
@@ -31,6 +33,14 @@ func ProjectIDWithName(cwd string) string {
return fmt.Sprintf("%s_%s", dirName, shortHash)
}
// HookDeadline returns a context with the hook's timeout budget minus a safety margin.
// This ensures hooks return gracefully before Claude kills them.
func HookDeadline(timeout time.Duration) (context.Context, context.CancelFunc) {
// Use 80% of the timeout to leave margin for response serialization
safeTimeout := time.Duration(float64(timeout) * 0.8)
return context.WithTimeout(context.Background(), safeTimeout)
}
// Exit codes for Claude Code hooks
const (
ExitSuccess = 0
@@ -92,7 +102,7 @@ func RunHook[T any](hookName string, handler HookHandler[T]) {
// Parse input
var input T
if err := json.Unmarshal(inputData, &input); err != nil {
if err = json.Unmarshal(inputData, &input); err != nil {
WriteError(hookName, err)
os.Exit(1)
}
+255 -17
View File
@@ -3,6 +3,7 @@ package hooks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
@@ -12,6 +13,8 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
@@ -22,13 +25,53 @@ const (
// DefaultWorkerPort is the default worker port.
DefaultWorkerPort = 37777
// HealthCheckTimeout is the timeout for health checks (reduced from 5s for faster startup).
HealthCheckTimeout = 1 * time.Second
// HealthCheckTimeout is the timeout for health checks.
HealthCheckTimeout = 2 * time.Second
// StartupTimeout is the timeout for worker startup.
StartupTimeout = 30 * time.Second
// workerCacheMaxAge is how long the worker cache is considered fresh.
workerCacheMaxAge = 10 * time.Second
// circuitBreakerCooldown is how long to wait after a startup failure before retrying.
circuitBreakerCooldown = 30 * time.Second
// healthCheckRetries is the number of health check attempts before declaring dead.
healthCheckRetries = 3
// healthCheckRetryDelay is the delay between health check retries.
healthCheckRetryDelay = 200 * time.Millisecond
)
var (
// circuitBreakerMu protects lastStartupFailure.
circuitBreakerMu sync.Mutex
lastStartupFailure time.Time
)
// IsWorkerAvailable performs a fast check without network calls.
// Returns true if the worker is likely available, false if definitely down.
func IsWorkerAvailable() bool {
// Check circuit breaker first
circuitBreakerMu.Lock()
if !lastStartupFailure.IsZero() && time.Since(lastStartupFailure) < circuitBreakerCooldown {
circuitBreakerMu.Unlock()
return false
}
circuitBreakerMu.Unlock()
// Check PID cache
entry := readWorkerCache()
if entry == nil {
return true // No cache = unknown, don't block
}
// Cache exists and is fresh (readWorkerCache already checks staleness)
// Check if cached process is alive
return isProcessAlive(entry.PID)
}
// GetWorkerPort returns the worker port from environment or default.
func GetWorkerPort() int {
if port := os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT"); port != "" {
@@ -40,29 +83,149 @@ func GetWorkerPort() int {
}
// IsWorkerRunning checks if the worker is running and healthy.
// Parses the JSON health response to check the "ready" field when available.
// Falls back to HTTP status code check for backwards compatibility.
func IsWorkerRunning(port int) bool {
client := &http.Client{Timeout: HealthCheckTimeout}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
if err != nil {
return false
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
// Try to parse JSON response for structured health check
var health struct {
Ready bool `json:"ready"`
}
if err := json.NewDecoder(resp.Body).Decode(&health); err == nil {
return health.Ready
}
// Fallback: treat HTTP 200 as healthy (backwards compatibility)
return resp.StatusCode == http.StatusOK
}
// workerCachePath returns the path to the worker cache file.
func workerCachePath() string {
home := os.Getenv("HOME")
if home == "" {
return ""
}
return filepath.Join(home, ".claude-mnemonic", ".worker-cache")
}
// workerCacheEntry holds cached worker state: "port:pid:timestamp".
type workerCacheEntry struct {
Timestamp time.Time
Port int
PID int
}
// readWorkerCache reads the worker cache file and returns the entry if fresh.
func readWorkerCache() *workerCacheEntry {
path := workerCachePath()
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
parts := strings.SplitN(strings.TrimSpace(string(data)), ":", 3)
if len(parts) != 3 {
return nil
}
port, err := strconv.Atoi(parts[0])
if err != nil || port <= 0 {
return nil
}
pid, err := strconv.Atoi(parts[1])
if err != nil || pid <= 0 {
return nil
}
ts, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return nil
}
entry := &workerCacheEntry{
Port: port,
PID: pid,
Timestamp: time.Unix(ts, 0),
}
// Check freshness
if time.Since(entry.Timestamp) > workerCacheMaxAge {
return nil
}
return entry
}
// writeWorkerCache writes the worker cache file.
func writeWorkerCache(port, pid int) {
path := workerCachePath()
if path == "" {
return
}
// Ensure directory exists
dir := filepath.Dir(path)
_ = os.MkdirAll(dir, 0o700)
data := fmt.Sprintf("%d:%d:%d", port, pid, time.Now().Unix())
_ = os.WriteFile(path, []byte(data), 0o600)
}
// isProcessAlive checks if a process with the given PID exists and is alive.
func isProcessAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
// Signal 0 checks if process exists without actually sending a signal.
err = proc.Signal(syscall.Signal(0))
return err == nil
}
// isWorkerRunningWithRetries checks if the worker is running, retrying on timeout.
// Returns true only if health check succeeds. Returns false if all retries fail.
func isWorkerRunningWithRetries(port int) bool {
for i := 0; i < healthCheckRetries; i++ {
if IsWorkerRunning(port) {
return true
}
if i < healthCheckRetries-1 {
time.Sleep(healthCheckRetryDelay)
}
}
return false
}
// EnsureWorkerRunning ensures the worker is running, starting it if necessary.
// If a worker is already running and healthy with matching version, it reuses it.
// If version mismatch or unhealthy, it kills the old worker and starts fresh.
func EnsureWorkerRunning() (int, error) {
port := GetWorkerPort()
// Check if already running and healthy
if IsWorkerRunning(port) {
// Fast path: check PID cache before making any HTTP calls.
if entry := readWorkerCache(); entry != nil && entry.Port == port {
if isProcessAlive(entry.PID) {
return port, nil
}
}
// Circuit breaker: if we failed to start recently, don't retry immediately.
circuitBreakerMu.Lock()
if !lastStartupFailure.IsZero() && time.Since(lastStartupFailure) < circuitBreakerCooldown {
circuitBreakerMu.Unlock()
return 0, fmt.Errorf("worker startup failed recently (circuit breaker open, retry after %v)", circuitBreakerCooldown-time.Since(lastStartupFailure))
}
circuitBreakerMu.Unlock()
// Check if already running and healthy (with retries to avoid false negatives under load)
if isWorkerRunningWithRetries(port) {
// Check version - if mismatch, restart (unless both are dev builds)
if runningVersion := GetWorkerVersion(port); runningVersion != "" {
if runningVersion != Version {
// For dev/dirty builds, don't restart if base versions match
if versionsCompatible(runningVersion, Version) {
updateCacheFromPort(port)
return port, nil
}
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker version mismatch (running: %s, expected: %s), restarting...\n", runningVersion, Version)
@@ -72,23 +235,34 @@ func EnsureWorkerRunning() (int, error) {
time.Sleep(500 * time.Millisecond)
} else {
// Version matches, reuse existing worker
updateCacheFromPort(port)
return port, nil
}
} else {
// Couldn't get version, assume it's fine
updateCacheFromPort(port)
return port, nil
}
}
// Check if port is in use but worker is unhealthy
// Port is in use but health check failed -- worker may be slow, not dead.
if IsPortInUse(port) {
// Something is using the port but not responding to health checks
// Try to kill it
// The port is responding to TCP but health check timed out.
// Don't kill it -- it's likely just under load. Give it more time.
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker on port %d is slow to respond, waiting...\n", port)
// Try a few more times with longer delays before giving up
for i := 0; i < 3; i++ {
time.Sleep(500 * time.Millisecond)
if IsWorkerRunning(port) {
updateCacheFromPort(port)
return port, nil
}
}
// Still not healthy after extended wait -- kill and restart
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker unresponsive after extended wait, restarting...\n")
if err := KillProcessOnPort(port); err != nil {
// Log but continue - maybe it will die on its own
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill unhealthy process on port %d: %v\n", port, err)
}
// Wait a moment for port to be released
time.Sleep(500 * time.Millisecond)
}
@@ -103,9 +277,14 @@ func EnsureWorkerRunning() (int, error) {
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
circuitBreakerMu.Lock()
lastStartupFailure = time.Now()
circuitBreakerMu.Unlock()
return 0, fmt.Errorf("failed to start worker: %w", err)
}
pid := cmd.Process.Pid
// Wait for worker to be ready with exponential backoff
deadline := time.Now().Add(StartupTimeout)
backoff := 50 * time.Millisecond
@@ -113,6 +292,7 @@ func EnsureWorkerRunning() (int, error) {
for time.Now().Before(deadline) {
if IsWorkerRunning(port) {
writeWorkerCache(port, pid)
return port, nil
}
time.Sleep(backoff)
@@ -123,9 +303,31 @@ func EnsureWorkerRunning() (int, error) {
}
}
circuitBreakerMu.Lock()
lastStartupFailure = time.Now()
circuitBreakerMu.Unlock()
return 0, fmt.Errorf("worker failed to start within timeout")
}
// updateCacheFromPort finds the PID of the process on the port and updates the cache.
func updateCacheFromPort(port int) {
cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port)) // #nosec G204 -- port is from internal config
output, err := cmd.Output()
if err != nil {
return
}
pidStr := strings.TrimSpace(string(output))
// Take first PID if multiple
if idx := strings.Index(pidStr, "\n"); idx > 0 {
pidStr = pidStr[:idx]
}
pid, err := strconv.Atoi(pidStr)
if err != nil || pid <= 0 {
return
}
writeWorkerCache(port, pid)
}
// GetWorkerVersion gets the version of the running worker.
func GetWorkerVersion(port int) string {
client := &http.Client{Timeout: HealthCheckTimeout}
@@ -133,7 +335,7 @@ func GetWorkerVersion(port int) string {
if err != nil {
return ""
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return ""
@@ -208,8 +410,6 @@ func findWorkerBinary() string {
locations := []string{
"./worker",
"./bin/worker",
filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0/worker"),
filepath.Join(home, ".claude/plugins/marketplaces/claude-mnemonic/worker"),
}
for _, loc := range locations {
@@ -218,6 +418,19 @@ func findWorkerBinary() string {
}
}
// Try cache directory with any version (glob returns lexically sorted matches)
matches, _ := filepath.Glob(filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/*/worker"))
if len(matches) > 0 {
// Use the last match (latest version due to lexical sorting)
return matches[len(matches)-1]
}
// Try marketplaces directory
marketplacePath := filepath.Join(home, ".claude/plugins/marketplaces/claude-mnemonic/worker")
if _, err := os.Stat(marketplacePath); err == nil {
return marketplacePath
}
// Try PATH
if path, err := exec.LookPath("claude-mnemonic-worker"); err == nil {
return path
@@ -243,7 +456,7 @@ func POST(port int, path string, body interface{}) (map[string]interface{}, erro
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("request failed: %s", resp.Status)
@@ -251,13 +464,38 @@ func POST(port int, path string, body interface{}) (map[string]interface{}, erro
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// Not all endpoints return JSON body - return empty map for success with no body
return map[string]interface{}{}, nil
// Not all endpoints return JSON
return nil, nil
}
return result, nil
}
// POSTWithContext sends a POST request using the provided context.
// Used for fire-and-forget calls where we want to control the timeout externally.
func POSTWithContext(ctx context.Context, port int, path string, body interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
bytes.NewReader(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
return nil
}
// GET sends a GET request to the worker.
func GET(port int, path string) (map[string]interface{}, error) {
client := &http.Client{Timeout: 10 * time.Second}
@@ -266,7 +504,7 @@ func GET(port int, path string) (map[string]interface{}, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("request failed: %s", resp.Status)
+1 -1
View File
@@ -517,7 +517,7 @@ func TestProjectIDWithName_Uniqueness(t *testing.T) {
// TestHookConstants tests hook-related constants.
func TestHookConstants(t *testing.T) {
assert.Equal(t, 37777, DefaultWorkerPort)
assert.Equal(t, 1*time.Second, HealthCheckTimeout)
assert.Equal(t, 2*time.Second, HealthCheckTimeout)
assert.Equal(t, 30*time.Second, StartupTimeout)
}
+63
View File
@@ -0,0 +1,63 @@
// Package sanitize provides content cleaning utilities for stripping
// Claude Code internal XML artifacts from captured text.
package sanitize
import (
"regexp"
"strings"
)
// systemXMLTags lists Claude Code internal XML tags that should be stripped
// from captured content before processing. These are system-level artifacts
// that pollute observations and summaries when stored.
var systemXMLTags = []string{
// Claude Code task/agent system
"task-notification",
// System reminders injected by Claude Code
"system-reminder",
// Claude-mnemonic's own context injection
"relevant-memory",
// Hook output wrappers
"user-prompt-submit-hook",
// Large output persistence
"persisted-output",
// Tool loading system
"available-deferred-tools",
// Fast mode info
"fast_mode_info",
// Anthropic internal
"antml_thinking",
"antml_function_calls",
}
// systemXMLRegexps are compiled regexps for each tag, built once at init.
var systemXMLRegexps []*regexp.Regexp
func init() {
systemXMLRegexps = make([]*regexp.Regexp, len(systemXMLTags))
for i, tag := range systemXMLTags {
// Match opening tag (with optional attributes), content (including newlines), and closing tag
systemXMLRegexps[i] = regexp.MustCompile(`(?s)<` + regexp.QuoteMeta(tag) + `[^>]*>.*?</` + regexp.QuoteMeta(tag) + `>`)
}
}
// StripSystemXML removes known Claude Code internal XML blocks from text.
// This prevents system artifacts like <task-notification>, <system-reminder>,
// and <relevant-memory> from being stored in observations and summaries.
func StripSystemXML(s string) string {
// Quick check: if no angle brackets, nothing to strip
if !strings.Contains(s, "<") {
return s
}
for _, re := range systemXMLRegexps {
s = re.ReplaceAllString(s, "")
}
// Clean up resulting double-blank-lines from removed blocks
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
return strings.TrimSpace(s)
}
+113
View File
@@ -0,0 +1,113 @@
package sanitize
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStripSystemXML(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "no XML tags",
input: "Hello, this is plain text with no XML.",
expected: "Hello, this is plain text with no XML.",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "task-notification block",
input: "Before\n<task-notification>\n<task-id>abc123</task-id>\n<status>completed</status>\n<summary>Agent completed</summary>\n<result>Some result text</result>\n</task-notification>\nAfter",
expected: "Before\n\nAfter",
},
{
name: "system-reminder block",
input: "Content before\n<system-reminder>\nSome system reminder text\n</system-reminder>\nContent after",
expected: "Content before\n\nContent after",
},
{
name: "relevant-memory block",
input: "<relevant-memory>\n# Relevant Knowledge\n## 1. Something\n</relevant-memory>\nActual content",
expected: "Actual content",
},
{
name: "system-reminder with attributes",
input: "Before <system-reminder type=\"hook\">reminder text</system-reminder> After",
expected: "Before After",
},
{
name: "multiple different tags",
input: "Start\n<task-notification><status>done</status></task-notification>\nMiddle\n<system-reminder>reminder</system-reminder>\nEnd",
expected: "Start\n\nMiddle\n\nEnd",
},
{
name: "persisted-output block",
input: "Result: <persisted-output>\nLarge output stored at: /tmp/foo\n</persisted-output> done",
expected: "Result: done",
},
{
name: "available-deferred-tools block",
input: "<available-deferred-tools>\nTool1\nTool2\n</available-deferred-tools>\nUser message",
expected: "User message",
},
{
name: "fast_mode_info block",
input: "Text <fast_mode_info>\nFast mode uses same model\n</fast_mode_info> more text",
expected: "Text more text",
},
{
name: "preserves non-system XML",
input: "<observation><type>bugfix</type><title>Fixed thing</title></observation>",
expected: "<observation><type>bugfix</type><title>Fixed thing</title></observation>",
},
{
name: "no angle brackets fast path",
input: "Just plain text without any special characters",
expected: "Just plain text without any special characters",
},
{
name: "collapses triple newlines",
input: "Before\n<system-reminder>x</system-reminder>\n\n\nAfter",
expected: "Before\n\nAfter",
},
{
name: "real-world task notification",
input: "Here is the analysis:\n<task-notification>\n<task-id>a077fd04aed547ce1</task-id>\n<tool-use-id>toolu_01Kw2GwsArQQR1F9aV3VfzhP</tool-use-id>\n<status>completed</status>\n<summary>Agent \"Analyze agency-agents repo structure\" completed</summary>\n<result>Now I have all the information I need. Let me compile a comprehensive report.\n\n## Agency-Agents Directory Exploration Report\n\n### 1. Agent File Count and Organization\n\n**Total Agent Files (excluding strategy docs):** 61 agents across 9 categories</result>\n</task-notification>\nThe repo has 61 agents.",
expected: "Here is the analysis:\n\nThe repo has 61 agents.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := StripSystemXML(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func BenchmarkStripSystemXML(b *testing.B) {
// Text with no XML (fast path)
b.Run("no_xml", func(b *testing.B) {
text := "This is plain text without any XML tags at all."
b.ResetTimer()
for i := 0; i < b.N; i++ {
StripSystemXML(text)
}
})
// Text with system XML to strip
b.Run("with_xml", func(b *testing.B) {
text := "Before\n<task-notification>\n<task-id>abc</task-id>\n<status>done</status>\n</task-notification>\n<system-reminder>reminder text here</system-reminder>\nAfter"
b.ResetTimer()
for i := 0; i < b.N; i++ {
StripSystemXML(text)
}
})
}
+99
View File
@@ -2,6 +2,7 @@
package similarity
import (
"math/bits"
"strings"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
@@ -15,6 +16,17 @@ func ClusterObservations(observations []*models.Observation, similarityThreshold
return observations
}
// For small sets, use the simple O(n²) algorithm
if len(observations) <= 50 {
return clusterObservationsSimple(observations, similarityThreshold)
}
// For larger sets, use an optimized approach with early termination
return clusterObservationsOptimized(observations, similarityThreshold)
}
// clusterObservationsSimple is the simple O(n²) algorithm for small sets.
func clusterObservationsSimple(observations []*models.Observation, similarityThreshold float64) []*models.Observation {
// Extract terms for each observation
termSets := make([]map[string]bool, len(observations))
for i, obs := range observations {
@@ -51,6 +63,93 @@ func ClusterObservations(observations []*models.Observation, similarityThreshold
return result
}
// clusterObservationsOptimized uses MinHash-based approximation for large sets.
// This reduces complexity from O(n²) to approximately O(n*k) where k is the number of hash functions.
func clusterObservationsOptimized(observations []*models.Observation, similarityThreshold float64) []*models.Observation {
n := len(observations)
// Extract terms for each observation and compute a signature
type termSetWithSig struct {
terms map[string]bool
signature uint64 // Simple hash signature for fast comparison
}
termSets := make([]termSetWithSig, n)
for i, obs := range observations {
terms := ExtractObservationTerms(obs)
termSets[i] = termSetWithSig{
terms: terms,
signature: computeTermSignature(terms),
}
}
// Track which observations are already clustered
clustered := make([]bool, n)
result := make([]*models.Observation, 0, n/2) // Pre-allocate assuming ~50% are unique
for i := 0; i < n; i++ {
if clustered[i] {
continue
}
// This observation becomes the representative of its cluster
result = append(result, observations[i])
clustered[i] = true
// Use signature for fast pre-filtering
sigI := termSets[i].signature
termsI := termSets[i].terms
// Find all similar observations and mark them as clustered
for j := i + 1; j < n; j++ {
if clustered[j] {
continue
}
// Quick signature comparison - if signatures are very different, skip detailed comparison
sigJ := termSets[j].signature
sigDiff := sigI ^ sigJ
popCount := popCount64(sigDiff)
// If signatures differ significantly, similarity is likely low
// Skip detailed comparison for very different signatures
if popCount > 32 { // More than half of bits differ
continue
}
// Full Jaccard comparison for candidates
similarity := JaccardSimilarity(termsI, termSets[j].terms)
if similarity >= similarityThreshold {
clustered[j] = true
}
}
}
return result
}
// computeTermSignature creates a quick hash signature for term sets.
// Used for fast pre-filtering in the optimized clustering algorithm.
func computeTermSignature(terms map[string]bool) uint64 {
var sig uint64
for term := range terms {
// Simple hash using FNV-1a inspired approach
h := uint64(14695981039346656037)
for i := 0; i < len(term); i++ {
h ^= uint64(term[i])
h *= 1099511628211
}
sig ^= h
}
return sig
}
// popCount64 counts the number of set bits in a 64-bit integer.
// Uses the stdlib bits.OnesCount64 which may use CPU POPCNT instruction.
func popCount64(x uint64) int {
return bits.OnesCount64(x)
}
// IsSimilarToAny checks if a new observation is similar to any existing observation.
// Returns true if similarity to any existing observation exceeds the threshold.
func IsSimilarToAny(newObs *models.Observation, existing []*models.Observation, similarityThreshold float64) bool {
+331
View File
@@ -290,3 +290,334 @@ func TestClusterObservations_PreservesOrder(t *testing.T) {
require.NotEmpty(t, clustered)
assert.Equal(t, int64(1), clustered[0].ID, "First observation should be kept as first result")
}
// =============================================================================
// TESTS FOR OPTIMIZED CLUSTERING (triggered when len(observations) > 50)
// =============================================================================
func TestClusterObservationsOptimized_LargeSet(t *testing.T) {
t.Parallel()
// Create 60 observations to trigger optimized path (threshold is 50)
observations := make([]*models.Observation, 60)
// Create 30 pairs of similar observations
topics := []string{
"authentication", "authorization", "database", "caching", "logging",
"monitoring", "testing", "deployment", "scaling", "security",
"networking", "storage", "messaging", "scheduling", "configuration",
"validation", "serialization", "encryption", "compression", "indexing",
"backup", "recovery", "migration", "versioning", "documentation",
"profiling", "debugging", "tracing", "alerting", "reporting",
}
for i := 0; i < 30; i++ {
// First observation of pair
observations[i*2] = &models.Observation{
ID: int64(i*2 + 1),
Title: sql.NullString{String: topics[i] + " implementation", Valid: true},
Narrative: sql.NullString{String: "Detailed " + topics[i] + " system design", Valid: true},
}
// Second observation of pair (similar to first)
observations[i*2+1] = &models.Observation{
ID: int64(i*2 + 2),
Title: sql.NullString{String: topics[i] + " update", Valid: true},
Narrative: sql.NullString{String: "Updated " + topics[i] + " logic", Valid: true},
}
}
clustered := ClusterObservations(observations, 0.4)
// With similar pairs, we should get roughly 30 clusters (one per topic)
t.Logf("Clustered %d observations down to %d", len(observations), len(clustered))
assert.Less(t, len(clustered), 60, "Similar observations should be clustered together")
assert.GreaterOrEqual(t, len(clustered), 1, "Should have at least one cluster")
}
func TestClusterObservationsOptimized_AllUnique(t *testing.T) {
t.Parallel()
// Create 55 completely unique observations with NO shared terms
// Each observation has only its unique term (no common words like "topic" or "content")
uniqueTerms := []string{
"aardvark", "butterfly", "caterpillar", "dragonfly", "elephant",
"flamingo", "giraffe", "hippopotamus", "iguana", "jellyfish",
"kangaroo", "leopard", "mongoose", "nightingale", "octopus",
"penguin", "quail", "rhinoceros", "salamander", "toucan",
"umbrella", "vulture", "walrus", "xylophone", "yakking",
"zebra123", "astronomy99", "biology88", "chemistry77", "dynamics66",
"economics55", "forensics44", "genetics33", "hydraulics22", "immunology11",
"jurisprudence", "kinetics", "linguistics", "metallurgy", "neurology",
"oceanography", "pharmacology", "quantumphysics", "robotics", "sociology",
"thermodynamics", "ultrasound", "virology", "wavelength", "xenobiology",
"yeastculture", "zoology123", "algebra456", "botany789", "calculus012",
}
observations := make([]*models.Observation, 55)
for i := 0; i < 55; i++ {
// Each observation has ONLY its unique term - no shared words
observations[i] = &models.Observation{
ID: int64(i + 1),
Title: sql.NullString{String: uniqueTerms[i], Valid: true},
Narrative: sql.NullString{String: uniqueTerms[i], Valid: true},
}
}
clustered := ClusterObservations(observations, 0.4)
// All unique content should remain unclustered
assert.Len(t, clustered, 55, "All unique observations should be kept")
}
func TestClusterObservationsOptimized_SignaturePrefiltering(t *testing.T) {
t.Parallel()
// Test that signature prefiltering works correctly
// Create observations where some have very different signatures
observations := make([]*models.Observation, 60)
// First half: all identical (about "authentication") - should cluster to 1
for i := 0; i < 30; i++ {
observations[i] = &models.Observation{
ID: int64(i + 1),
Title: sql.NullString{String: "authentication security login", Valid: true},
Narrative: sql.NullString{String: "JWT tokens OAuth authentication", Valid: true},
}
}
// Second half: each completely unique with NO shared terms
diffTerms := []string{
"quantumphysics", "photosynthesis", "archaeologydig", "linguisticstudy", "astronomystar",
"paleontologyfossil", "oceanographywave", "entomologybug", "mycologyfungi", "herpetologysnake",
"ornithologybird", "ichthyologyfish", "seismologyquake", "volcanologylava", "meteorologyrain",
"cartographymap", "ethnographyculture", "philologyword", "numismaticscoin", "heraldryshield",
"genealogytree", "chronologytime", "typographyfont", "calligraphyink", "epigraphystone",
"papyrologytext", "codicologybook", "diplomaticseal", "sigillographywax", "sphragisticsring",
}
for i := 30; i < 60; i++ {
term := diffTerms[i-30]
// Each has ONLY its unique term - no shared words
observations[i] = &models.Observation{
ID: int64(i + 1),
Title: sql.NullString{String: term, Valid: true},
Narrative: sql.NullString{String: term, Valid: true},
}
}
clustered := ClusterObservations(observations, 0.5)
// Should have 31 clusters: 1 for all auth topics + 30 unique topics
t.Logf("Clustered %d observations down to %d", len(observations), len(clustered))
assert.Equal(t, 31, len(clustered), "Should have 31 clusters (1 auth + 30 unique)")
}
// =============================================================================
// TESTS FOR HELPER FUNCTIONS
// =============================================================================
func TestComputeTermSignature(t *testing.T) {
tests := []struct {
terms map[string]bool
compareTo map[string]bool
name string
expectZero bool
expectSame bool
}{
// ===== GOOD CASES =====
{
name: "single term",
terms: map[string]bool{"hello": true},
expectZero: false,
},
{
name: "multiple terms",
terms: map[string]bool{"hello": true, "world": true},
expectZero: false,
},
{
name: "identical terms produce same signature",
terms: map[string]bool{"alpha": true, "beta": true},
expectSame: true,
compareTo: map[string]bool{"alpha": true, "beta": true},
},
// ===== EDGE CASES =====
{
name: "empty set",
terms: map[string]bool{},
expectZero: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sig := computeTermSignature(tt.terms)
if tt.expectZero {
assert.Equal(t, uint64(0), sig, "Empty set should produce zero signature")
} else {
assert.NotEqual(t, uint64(0), sig, "Non-empty set should produce non-zero signature")
}
if tt.expectSame && tt.compareTo != nil {
sig2 := computeTermSignature(tt.compareTo)
assert.Equal(t, sig, sig2, "Identical term sets should produce identical signatures")
}
})
}
}
func TestComputeTermSignature_DifferentSets(t *testing.T) {
t.Parallel()
// Different term sets should usually produce different signatures
set1 := map[string]bool{"authentication": true, "security": true}
set2 := map[string]bool{"database": true, "migration": true}
sig1 := computeTermSignature(set1)
sig2 := computeTermSignature(set2)
// While hash collisions are possible, they should be rare
assert.NotEqual(t, sig1, sig2, "Different term sets should usually produce different signatures")
}
func TestPopCount64(t *testing.T) {
tests := []struct {
name string
input uint64
expected int
}{
// ===== GOOD CASES =====
{name: "zero", input: 0, expected: 0},
{name: "one", input: 1, expected: 1},
{name: "powers of two", input: 8, expected: 1},
{name: "all ones in byte", input: 0xFF, expected: 8},
{name: "alternating bits", input: 0xAAAAAAAAAAAAAAAA, expected: 32},
{name: "max uint64", input: 0xFFFFFFFFFFFFFFFF, expected: 64},
// ===== EDGE CASES =====
{name: "single high bit", input: 1 << 63, expected: 1},
{name: "sparse bits", input: 0x8000000000000001, expected: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := popCount64(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsSimilarToAny_EmptyTerms(t *testing.T) {
t.Parallel()
// Observation with no extractable terms
emptyObs := &models.Observation{
ID: 1,
Title: sql.NullString{String: "", Valid: false},
Narrative: sql.NullString{String: "", Valid: false},
}
existing := []*models.Observation{
{
ID: 2,
Title: sql.NullString{String: "Some content here", Valid: true},
Narrative: sql.NullString{String: "More content", Valid: true},
},
}
// Should return false when new observation has no terms
assert.False(t, IsSimilarToAny(emptyObs, existing, 0.3))
}
func TestExtractObservationTerms_FilesModified(t *testing.T) {
t.Parallel()
obs := &models.Observation{
ID: 1,
Title: sql.NullString{String: "Code changes", Valid: true},
FilesModified: models.JSONStringArray{"/src/handler.go", "/pkg/models/user.go"},
}
terms := ExtractObservationTerms(obs)
// Should contain filenames from FilesModified
assert.Contains(t, terms, "handler.go")
assert.Contains(t, terms, "user.go")
}
func TestAddTerms_ShortWords(t *testing.T) {
t.Parallel()
terms := make(map[string]bool)
addTerms(terms, "I am a go developer")
// Short words (< 3 chars) should be excluded
assert.NotContains(t, terms, "i")
assert.NotContains(t, terms, "am")
assert.NotContains(t, terms, "a")
assert.NotContains(t, terms, "go") // Only 2 chars
// "developer" should be included
assert.Contains(t, terms, "developer")
}
func TestAddTerms_SpecialCharacters(t *testing.T) {
t.Parallel()
terms := make(map[string]bool)
addTerms(terms, "user_id authentication-flow JWT_token")
// Hyphens split words, but underscores are kept as part of the word
// (underscore is included in the tokenization regex)
assert.Contains(t, terms, "user_id")
assert.Contains(t, terms, "authentication")
assert.Contains(t, terms, "flow")
assert.Contains(t, terms, "jwt_token")
}
func TestJaccardSimilarity_SubsetSuperset(t *testing.T) {
t.Parallel()
subset := map[string]bool{"a": true, "b": true}
superset := map[string]bool{"a": true, "b": true, "c": true, "d": true}
// Subset similarity should be intersection/union = 2/4 = 0.5
result := JaccardSimilarity(subset, superset)
assert.InDelta(t, 0.5, result, 0.001)
}
func TestClusterObservations_HighThreshold(t *testing.T) {
t.Parallel()
// With a very high threshold, almost nothing should be clustered
observations := []*models.Observation{
{ID: 1, Title: sql.NullString{String: "authentication implementation", Valid: true}},
{ID: 2, Title: sql.NullString{String: "authentication update", Valid: true}},
{ID: 3, Title: sql.NullString{String: "authentication refactor", Valid: true}},
}
// With threshold of 0.9, even similar observations shouldn't cluster
clustered := ClusterObservations(observations, 0.9)
assert.Len(t, clustered, 3, "High threshold should prevent clustering")
}
func TestClusterObservations_LowThreshold(t *testing.T) {
t.Parallel()
// With a very low threshold, more things should be clustered
observations := []*models.Observation{
{ID: 1, Title: sql.NullString{String: "authentication implementation details", Valid: true}},
{ID: 2, Title: sql.NullString{String: "authentication security update", Valid: true}},
{ID: 3, Title: sql.NullString{String: "something completely different topic", Valid: true}},
}
// With threshold of 0.1, partial overlap should cluster
clustered := ClusterObservations(observations, 0.1)
// First two share "authentication", should likely cluster
assert.LessOrEqual(t, len(clustered), 3)
}
+10 -1
View File
@@ -5,5 +5,14 @@
"author": {
"name": "lukaszraczylo",
"email": "[email protected]"
}
},
"hooks": "./hooks/hooks.json",
"mcpServers": {
"claude-mnemonic": {
"command": "${CLAUDE_PLUGIN_ROOT}/mcp-server",
"args": ["--project", "${CLAUDE_PROJECT}"],
"env": {}
}
},
"commands": ["./commands/restart.md"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
set -e
ONNX_VERSION="1.23.2"
ONNX_VERSION="1.24.3"
ASSETS_DIR="internal/embedding/assets/lib"
PLATFORM="${1:-all}"
FORCE_DOWNLOAD=false
+138 -85
View File
@@ -7,6 +7,8 @@
set -e
INSTALLER_VERSION="1.1.0"
# Configuration
GITHUB_REPO="lukaszraczylo/claude-mnemonic"
INSTALL_DIR="$HOME/.claude/plugins/marketplaces/claude-mnemonic"
@@ -40,6 +42,50 @@ error() {
exit 1
}
# Gracefully stop worker processes (SIGTERM first, then SIGKILL after timeout)
graceful_stop_worker() {
# Send SIGTERM first
pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
if command -v lsof &> /dev/null; then
lsof -ti :37777 2>/dev/null | xargs kill -TERM 2>/dev/null || true
elif command -v ss &> /dev/null; then
ss -tlnp 'sport = :37777' 2>/dev/null | awk 'NR>1 {print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -TERM 2>/dev/null || true
elif command -v fuser &> /dev/null; then
fuser -k -TERM 37777/tcp 2>/dev/null || true
fi
# Wait up to 5 seconds for graceful shutdown
local waited=0
while [[ $waited -lt 5 ]]; do
if ! pgrep -f 'claude-mnemonic.*worker' &>/dev/null && ! pgrep -f '\.claude/plugins/.*/worker' &>/dev/null; then
return 0
fi
sleep 1
waited=$((waited + 1))
done
# Force kill if still running
pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
if command -v lsof &> /dev/null; then
lsof -ti :37777 2>/dev/null | xargs kill -9 2>/dev/null || true
elif command -v ss &> /dev/null; then
ss -tlnp 'sport = :37777' 2>/dev/null | awk 'NR>1 {print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -9 2>/dev/null || true
elif command -v fuser &> /dev/null; then
fuser -k 37777/tcp 2>/dev/null || true
fi
sleep 1
# Remove stale PID cache to prevent hooks from using old worker info
rm -f "$HOME/.claude-mnemonic/.worker-cache" 2>/dev/null || true
# Verify process is gone
if pgrep -f 'claude-mnemonic.*worker' &>/dev/null; then
warn "Could not stop existing worker process"
fi
}
# Detect OS and architecture
detect_platform() {
local os arch
@@ -131,7 +177,7 @@ download_release() {
local tmp_dir
tmp_dir=$(mktemp -d)
trap "rm -rf $tmp_dir" EXIT
trap 'rm -rf "$tmp_dir"' EXIT
# Construct download URL (use .zip for Windows, .tar.gz for others)
local archive_ext="tar.gz"
@@ -147,8 +193,38 @@ download_release() {
error "Failed to download release from: $download_url"
fi
# Verify download integrity via checksum
local checksums_url="https://github.com/${GITHUB_REPO}/releases/download/${version}/checksums.txt"
info "Verifying download integrity..."
if curl -fsSL -o "$tmp_dir/checksums.txt" "$checksums_url" 2>/dev/null; then
local expected_hash actual_hash
expected_hash=$(grep "${archive_name}" "$tmp_dir/checksums.txt" | awk '{print $1}')
if [[ -z "$expected_hash" ]]; then
warn "Archive ${archive_name} not found in checksums.txt, skipping verification"
else
if command -v shasum &> /dev/null; then
actual_hash=$(shasum -a 256 "$tmp_dir/release.${archive_ext}" | awk '{print $1}')
elif command -v sha256sum &> /dev/null; then
actual_hash=$(sha256sum "$tmp_dir/release.${archive_ext}" | awk '{print $1}')
else
warn "No SHA256 tool found (shasum or sha256sum), skipping checksum verification"
actual_hash=""
fi
if [[ -n "$actual_hash" ]]; then
if [[ "$expected_hash" != "$actual_hash" ]]; then
error "Checksum verification failed! Expected: $expected_hash Got: $actual_hash"
fi
success "Checksum verified"
fi
fi
else
warn "No checksum file available, skipping verification"
fi
info "Extracting archive..."
if [[ "$archive_ext" == "zip" ]]; then
if ! command -v unzip &> /dev/null; then
error "unzip is required for Windows archives but not installed"
fi
if ! unzip -q "$tmp_dir/release.zip" -d "$tmp_dir"; then
error "Failed to extract archive"
fi
@@ -160,17 +236,7 @@ download_release() {
# Stop existing worker if running
info "Stopping existing worker (if running)..."
pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
# Kill process on port 37777 (use lsof on macOS, ss/fuser on Linux)
if command -v lsof &> /dev/null; then
lsof -ti :37777 | xargs kill -9 2>/dev/null || true
elif command -v ss &> /dev/null; then
ss -tlnp 'sport = :37777' 2>/dev/null | awk 'NR>1 {print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -9 2>/dev/null || true
elif command -v fuser &> /dev/null; then
fuser -k 37777/tcp 2>/dev/null || true
fi
sleep 1
graceful_stop_worker
# Create installation directories
info "Installing to ${INSTALL_DIR}..."
@@ -178,13 +244,21 @@ download_release() {
mkdir -p "$INSTALL_DIR/.claude-plugin"
mkdir -p "$INSTALL_DIR/commands"
# Copy binaries
cp "$tmp_dir/worker" "$INSTALL_DIR/"
cp "$tmp_dir/mcp-server" "$INSTALL_DIR/"
cp "$tmp_dir/hooks/"* "$INSTALL_DIR/hooks/"
# Copy binaries (abort on failure — could indicate disk full or permissions issue)
if ! cp "$tmp_dir/worker" "$INSTALL_DIR/"; then
error "Failed to copy worker binary to $INSTALL_DIR/"
fi
if ! cp "$tmp_dir/mcp-server" "$INSTALL_DIR/"; then
error "Failed to copy mcp-server binary to $INSTALL_DIR/"
fi
if ! cp "$tmp_dir/hooks/"* "$INSTALL_DIR/hooks/"; then
error "Failed to copy hook binaries to $INSTALL_DIR/hooks/"
fi
# Copy plugin configuration
cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/"
if ! cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/"; then
error "Failed to copy plugin configuration to $INSTALL_DIR/.claude-plugin/"
fi
# Copy slash commands if they exist in the release
if [[ -d "$tmp_dir/commands" ]]; then
@@ -338,72 +412,51 @@ start_worker() {
error "Worker binary not found at $worker_path"
fi
# Check for port conflict with a non-mnemonic process
if command -v lsof &> /dev/null; then
local port_pid
port_pid=$(lsof -ti :37777 2>/dev/null || true)
if [[ -n "$port_pid" ]]; then
local port_cmd
port_cmd=$(ps -p "$port_pid" -o comm= 2>/dev/null || true)
if [[ -n "$port_cmd" ]] && ! echo "$port_cmd" | grep -q "worker"; then
warn "Port 37777 is in use by another process: $port_cmd (PID $port_pid)"
warn "The worker may fail to start. Consider stopping the conflicting process."
fi
fi
fi
info "Starting worker service..."
nohup "$worker_path" > /tmp/claude-mnemonic-worker.log 2>&1 &
sleep 2
# Retry health check up to 5 times with 1s interval
local retries=0
local max_retries=5
while [[ $retries -lt $max_retries ]]; do
sleep 1
if curl -sS http://localhost:37777/health > /dev/null 2>&1; then
success "Worker started successfully at http://localhost:37777"
return 0
fi
retries=$((retries + 1))
done
if curl -sS http://localhost:37777/health > /dev/null 2>&1; then
success "Worker started successfully at http://localhost:37777"
else
warn "Worker may not have started properly. Check /tmp/claude-mnemonic-worker.log"
fi
warn "Worker may not have started properly after ${max_retries} attempts. Check /tmp/claude-mnemonic-worker.log"
}
# Check optional dependencies for semantic search
check_optional_deps() {
local missing_deps=()
local install_hints=""
# Semantic search uses embedded ONNX runtime - no external Python/uvx dependencies needed
success "Semantic search enabled (embedded ONNX runtime)"
}
# Check for Python 3.13+
if command -v python3 &> /dev/null; then
local py_version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
if [[ "$py_version" < "3.13" ]]; then
missing_deps+=("Python 3.13+ (found $py_version)")
fi
else
missing_deps+=("Python 3.13+")
fi
# Check for uvx
if ! command -v uvx &> /dev/null; then
missing_deps+=("uvx")
fi
if [[ ${#missing_deps[@]} -gt 0 ]]; then
echo ""
warn "Optional dependencies missing (needed for semantic search):"
for dep in "${missing_deps[@]}"; do
echo " - $dep"
done
echo ""
# Detect OS and show appropriate install command
case "$(uname -s)" in
Darwin)
info "Install on macOS:"
echo " brew install [email protected]"
echo " pip3 install uv"
;;
Linux)
info "Install on Linux:"
echo " sudo apt install python3 python3-pip"
echo " pip3 install uv"
;;
MINGW*|MSYS*|CYGWIN*)
info "Install on Windows:"
echo " winget install Python.Python.3.13"
echo " pip install uv"
;;
esac
echo ""
info "Note: Requires Python 3.13+. Most package managers install the latest version."
echo ""
info "Semantic search will be disabled until these are installed."
info "Core functionality (SQLite storage, full-text search) will work."
echo ""
else
success "Optional dependencies found (semantic search enabled)"
# Rollback partially installed files on failure
INSTALL_COMPLETE=false
cleanup_on_failure() {
if [[ "$INSTALL_COMPLETE" != "true" ]]; then
warn "Installation did not complete — cleaning up partial install..."
rm -rf "$INSTALL_DIR" 2>/dev/null || true
rm -rf "$CACHE_DIR" 2>/dev/null || true
fi
}
@@ -411,6 +464,8 @@ check_optional_deps() {
main() {
local version="${1:-}"
trap cleanup_on_failure EXIT
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ Claude Mnemonic - Installation Script ║"
@@ -455,6 +510,8 @@ main() {
# Check optional dependencies
check_optional_deps
INSTALL_COMPLETE=true
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ Installation Complete! ║"
@@ -467,6 +524,12 @@ main() {
echo ""
}
# Handle --version flag
if [[ "${1:-}" == "--version" ]]; then
echo "claude-mnemonic installer v${INSTALLER_VERSION}"
exit 0
fi
# Handle --register-only flag
if [[ "${1:-}" == "--register-only" ]]; then
version=$(cat "$INSTALL_DIR/.claude-plugin/plugin.json" 2>/dev/null | grep '"version"' | sed -E 's/.*"([^"]+)".*/\1/' || echo "1.0.0")
@@ -486,17 +549,7 @@ if [[ "${1:-}" == "--uninstall" ]]; then
echo ""
info "Stopping worker processes..."
pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
# Kill process on port 37777 (use lsof on macOS, ss/fuser on Linux)
if command -v lsof &> /dev/null; then
lsof -ti :37777 | xargs kill -9 2>/dev/null || true
elif command -v ss &> /dev/null; then
ss -tlnp 'sport = :37777' 2>/dev/null | awk 'NR>1 {print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -9 2>/dev/null || true
elif command -v fuser &> /dev/null; then
fuser -k 37777/tcp 2>/dev/null || true
fi
sleep 1
graceful_stop_worker
info "Removing plugin directories..."
rm -rf "$INSTALL_DIR"
+153 -52
View File
@@ -16,6 +16,35 @@ 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")
# Helper: safely write JSON via tmp file with validation
# Usage: safe_jq_write <jq_args...> <input_file>
# The last argument is treated as the input file, output goes to input_file.tmp
safe_jq_write() {
local args=("$@")
local input_file="${args[-1]}"
local tmp_file="${input_file}.tmp"
if jq "${args[@]}" > "$tmp_file"; then
if jq . "$tmp_file" > /dev/null 2>&1; then
mv "$tmp_file" "$input_file"
else
echo "ERROR: jq produced invalid JSON for $input_file, aborting"
rm -f "$tmp_file"
return 1
fi
else
echo "ERROR: jq failed for $input_file, aborting"
rm -f "$tmp_file"
return 1
fi
}
# Check that Claude Code directory exists
if [ ! -d "$HOME/.claude" ]; then
echo "Warning: $HOME/.claude directory not found. Claude Code may not be installed."
echo "Continuing anyway, but plugin may not function until Claude Code is installed."
fi
# Ensure plugins directory exists
mkdir -p "$HOME/.claude/plugins"
@@ -40,15 +69,119 @@ if [ ! -f "$MARKETPLACES_FILE" ]; then
echo '{}' > "$MARKETPLACES_FILE"
fi
# Check if jq is available
if command -v jq &> /dev/null; then
# Ensure cache directory exists and copy plugin files
mkdir -p "$CACHE_PATH/.claude-plugin"
mkdir -p "$CACHE_PATH/hooks"
mkdir -p "$CACHE_PATH/commands"
# Validate marketplace path exists and contains expected files
if [ ! -d "$MARKETPLACE_PATH" ]; then
echo "Warning: Marketplace directory not found at $MARKETPLACE_PATH"
echo "Plugin files may not be copied to cache correctly."
fi
# Copy files from marketplace to cache
cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true
# Ensure cache directory exists and copy plugin files
mkdir -p "$CACHE_PATH/.claude-plugin"
mkdir -p "$CACHE_PATH/hooks"
mkdir -p "$CACHE_PATH/commands"
# Copy files from marketplace to cache
if ! cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null; then
echo "ERROR: Failed to copy plugin files to cache directory"
exit 1
fi
# Verify critical files exist in cache
for f in worker mcp-server hooks/hooks.json .claude-plugin/plugin.json; do
if [ ! -f "$CACHE_PATH/$f" ]; then
echo "WARNING: Expected file $f not found in cache after copy"
fi
done
# --- JSON registration ---
# Uses jq if available, falls back to python3 for systems without jq.
STATUSLINE_CMD="\${CLAUDE_PLUGIN_ROOT}/hooks/statusline"
# Helper: register plugin using python3 (jq-free fallback)
register_with_python() {
python3 - "$PLUGINS_FILE" "$SETTINGS_FILE" "$MARKETPLACES_FILE" \
"$PLUGIN_KEY" "$CACHE_PATH" "$VERSION" "$TIMESTAMP" \
"$STATUSLINE_CMD" "$MARKETPLACE_NAME" "$MARKETPLACE_PATH" <<'PYEOF'
import json, sys, os
plugins_file, settings_file, marketplaces_file = sys.argv[1], sys.argv[2], sys.argv[3]
plugin_key, cache_path, version, timestamp = sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7]
statusline_cmd, marketplace_name, marketplace_path = sys.argv[8], sys.argv[9], sys.argv[10]
def load_json(path):
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
return {}
def save_json(path, data):
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
os.replace(tmp, path)
# 1. installed_plugins.json
plugins = load_json(plugins_file)
plugins.setdefault("version", 2)
plugins.setdefault("plugins", {})
plugins["plugins"][plugin_key] = [{
"scope": "user",
"installPath": cache_path,
"version": version,
"installedAt": timestamp,
"lastUpdated": timestamp,
"isLocal": True
}]
save_json(plugins_file, plugins)
print("Plugin registered in installed_plugins.json")
# 2. settings.json
settings = load_json(settings_file)
settings.setdefault("enabledPlugins", {})
settings["enabledPlugins"][plugin_key] = True
settings["statusLine"] = {
"type": "command",
"command": statusline_cmd,
"padding": 0
}
save_json(settings_file, settings)
print("Plugin enabled in settings.json")
print("Statusline configured in settings.json")
# 3. known_marketplaces.json
marketplaces = load_json(marketplaces_file)
marketplaces[marketplace_name] = {
"source": {"source": "directory", "path": marketplace_path},
"installLocation": marketplace_path,
"lastUpdated": timestamp
}
save_json(marketplaces_file, marketplaces)
print("Marketplace registered in known_marketplaces.json")
PYEOF
}
# Detect JSON tool and register
if command -v jq &> /dev/null; then
# Validate jq version (1.6+ required for //= operator)
JQ_VERSION=$(jq --version 2>/dev/null | sed 's/jq-//')
JQ_MAJOR=$(echo "$JQ_VERSION" | cut -d. -f1)
JQ_MINOR=$(echo "$JQ_VERSION" | cut -d. -f2)
if [ -n "$JQ_MAJOR" ] && [ -n "$JQ_MINOR" ]; then
if [ "$JQ_MAJOR" -lt 1 ] || { [ "$JQ_MAJOR" -eq 1 ] && [ "$JQ_MINOR" -lt 6 ]; }; then
echo "jq $JQ_VERSION found but too old (need 1.6+), trying python3..."
if command -v python3 &> /dev/null; then
register_with_python
else
echo "ERROR: jq 1.6+ or python3 is required for plugin registration"
exit 1
fi
echo "Plugin registered successfully using python3"
exit 0
fi
fi
# Use jq for proper JSON manipulation
PLUGIN_ENTRY=$(cat <<EOF
@@ -64,15 +197,12 @@ EOF
)
# Add or update the plugin entry in installed_plugins.json
jq --arg key "$PLUGIN_KEY" --argjson entry "$PLUGIN_ENTRY" \
'.plugins[$key] = $entry' "$PLUGINS_FILE" > "${PLUGINS_FILE}.tmp" \
&& mv "${PLUGINS_FILE}.tmp" "$PLUGINS_FILE"
safe_jq_write --arg key "$PLUGIN_KEY" --argjson entry "$PLUGIN_ENTRY" \
'.plugins[$key] = $entry' "$PLUGINS_FILE"
echo "Plugin registered in installed_plugins.json"
# Enable the plugin in settings.json and configure statusline
# First ensure enabledPlugins object exists, then add our plugin
STATUSLINE_CMD="$MARKETPLACE_PATH/hooks/statusline"
STATUSLINE_ENTRY=$(cat <<EOF
{
"type": "command",
@@ -82,9 +212,8 @@ EOF
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"
safe_jq_write --arg key "$PLUGIN_KEY" --argjson statusline "$STATUSLINE_ENTRY" \
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline' "$SETTINGS_FILE"
echo "Plugin enabled in settings.json"
echo "Statusline configured in settings.json"
@@ -102,45 +231,17 @@ EOF
EOF
)
jq --arg key "$MARKETPLACE_NAME" --argjson entry "$MARKETPLACE_ENTRY" \
'.[$key] = $entry' "$MARKETPLACES_FILE" > "${MARKETPLACES_FILE}.tmp" \
&& mv "${MARKETPLACES_FILE}.tmp" "$MARKETPLACES_FILE"
safe_jq_write --arg key "$MARKETPLACE_NAME" --argjson entry "$MARKETPLACE_ENTRY" \
'.[$key] = $entry' "$MARKETPLACES_FILE"
echo "Marketplace registered in known_marketplaces.json"
# Register MCP server in settings.json
MCP_BINARY="$MARKETPLACE_PATH/mcp-server"
if [ -f "$MCP_BINARY" ]; then
echo "Registering MCP server in settings.json..."
# MCP server entry - note the escaped ${CLAUDE_PROJECT}
MCP_ENTRY=$(cat <<'EOF'
{
"command": "MCP_BINARY_PLACEHOLDER",
"args": ["--project", "${CLAUDE_PROJECT}"],
"env": {}
}
EOF
)
# Replace placeholder with actual path
MCP_ENTRY=$(echo "$MCP_ENTRY" | sed "s|MCP_BINARY_PLACEHOLDER|$MCP_BINARY|g")
# Add or update mcpServers field
if jq --arg key "claude-mnemonic" --argjson entry "$MCP_ENTRY" \
'.mcpServers //= {} | .mcpServers[$key] = $entry' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp"; then
mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
echo "MCP server registered successfully"
else
echo "Warning: Failed to register MCP server (jq error)"
rm -f "${SETTINGS_FILE}.tmp"
fi
else
echo "MCP server binary not found at $MCP_BINARY, skipping MCP registration"
fi
echo "Plugin registered successfully using jq"
elif command -v python3 &> /dev/null; then
register_with_python
echo "Plugin registered successfully using python3"
else
echo "ERROR: jq is required for plugin registration"
echo "Please install jq: brew install jq (macOS) or apt-get install jq (Linux)"
echo "ERROR: jq or python3 is required for plugin registration"
echo "Please install one: brew install jq (macOS) or apt-get install jq (Linux)"
exit 1
fi
+16 -3
View File
@@ -3,6 +3,18 @@
set -e
# Stop running worker processes before removing binaries
echo "Stopping worker processes..."
pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
sleep 2
# Force kill if still running
pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
# Clean up port
lsof -ti :37777 | xargs kill -9 2>/dev/null || true
sleep 1
PLUGINS_FILE="$HOME/.claude/plugins/installed_plugins.json"
SETTINGS_FILE="$HOME/.claude/settings.json"
MARKETPLACES_FILE="$HOME/.claude/plugins/known_marketplaces.json"
@@ -30,16 +42,17 @@ else
echo "No plugins file found, skipping"
fi
# Remove from settings.json (enabledPlugins and statusLine if it points to our plugin)
# Remove from settings.json (enabledPlugins, statusLine, and mcpServers)
if [ -f "$SETTINGS_FILE" ]; then
# Remove from enabledPlugins and clear statusLine if it references our plugin
# Remove from enabledPlugins, clear statusLine if it references our plugin, and remove MCP server
jq --arg key "$PLUGIN_KEY" '
del(.enabledPlugins[$key]) |
if .statusLine.command and (.statusLine.command | contains("claude-mnemonic")) then
del(.statusLine)
else
.
end
end |
del(.mcpServers["claude-mnemonic"])
' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \
&& mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
echo "Plugin removed from settings.json"
-1
View File
@@ -4,7 +4,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Mnemonic Dashboard</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 min-h-screen text-white">
<div id="app"></div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.10.5-1-g7ab4b07-dirty",
"version": "v0.11.47-3-gfcab3eb-dirty",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mnemonic-dashboard",
"version": "v0.10.5-1-g7ab4b07-dirty",
"version": "v0.11.47-3-gfcab3eb-dirty",
"dependencies": {
"vis-data": "^7.1.9",
"vis-network": "^9.1.9",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.10.5-1-g7ab4b07-dirty",
"version": "v0.11.47-3-gfcab3eb-dirty",
"private": true,
"type": "module",
"scripts": {
+17 -5
View File
@@ -8,6 +8,7 @@ import Card from './Card.vue'
import IconBox from './IconBox.vue'
import Badge from './Badge.vue'
import RelationGraph from './RelationGraph.vue'
import ScoreBreakdown from './ScoreBreakdown.vue'
import { computed, ref, onMounted } from 'vue'
const props = defineProps<{
@@ -95,6 +96,9 @@ const relationsLoading = ref(false)
const relationsExpanded = ref(false)
const showGraph = ref(false)
// Score breakdown state
const showScoreBreakdown = ref(false)
const hasRelations = computed(() => relations.value.length > 0)
const relationCount = computed(() => relations.value.length)
@@ -350,14 +354,15 @@ const splitPath = (path: string, components = 3) => {
<i class="fas fa-thumbs-up text-sm" />
</button>
<span
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-400 flex items-center gap-1 transition-all duration-300"
<button
@click="showScoreBreakdown = true"
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-400 flex items-center gap-1 transition-all duration-300 hover:bg-purple-500/20 hover:text-purple-300 cursor-pointer"
:class="{ 'text-green-400': localScore !== null && localScore > (observation.importance_score || 1), 'text-red-400': localScore !== null && localScore < (observation.importance_score || 1) }"
:title="`Importance Score: ${currentScore.toFixed(3)}\nRetrieval Count: ${observation.retrieval_count || 0}`"
:title="`Importance Score: ${currentScore.toFixed(3)}\nRetrieval Count: ${observation.retrieval_count || 0}\nClick for details`"
>
<i class="fas fa-scale-balanced text-amber-500/60" />
<i class="fas fa-chart-bar text-purple-500/60" />
{{ currentScore.toFixed(2) }}
</span>
</button>
<button
@click="submitFeedback(-1)"
@@ -383,5 +388,12 @@ const splitPath = (path: string, components = 3) => {
@close="showGraph = false"
@navigate-to="handleNavigateTo"
/>
<!-- Score Breakdown Modal -->
<ScoreBreakdown
:observation-id="observation.id"
:show="showScoreBreakdown"
@close="showScoreBreakdown = false"
/>
</Card>
</template>
+202
View File
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { fetchObservationScore, type ScoreBreakdown } from '@/utils/api'
import Card from './Card.vue'
const props = defineProps<{
observationId: number
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const data = ref<ScoreBreakdown | null>(null)
const loadScore = async () => {
if (!props.observationId) return
loading.value = true
error.value = null
try {
data.value = await fetchObservationScore(props.observationId)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load score breakdown'
} finally {
loading.value = false
}
}
// Load on mount and when ID changes
onMounted(() => {
if (props.show) loadScore()
})
watch(() => props.show, (newVal) => {
if (newVal) loadScore()
})
watch(() => props.observationId, () => {
if (props.show) loadScore()
})
// Score bar helper
const getScoreBarWidth = (value: number, max: number = 2) => {
return `${Math.min(100, Math.max(0, (value / max) * 100))}%`
}
// Score color helper
const getScoreColor = (value: number) => {
if (value >= 1.5) return 'bg-green-500'
if (value >= 1) return 'bg-amber-500'
if (value >= 0.5) return 'bg-orange-500'
return 'bg-red-500'
}
</script>
<template>
<!-- Modal Backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal Content -->
<div class="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<Card
gradient="bg-gradient-to-br from-purple-500/10 to-indigo-500/5"
border-class="border-purple-500/30"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-chart-bar text-purple-400" />
<h3 class="text-lg font-semibold text-purple-100">Score Breakdown</h3>
</div>
<button
@click="emit('close')"
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-2xl text-purple-400" />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="data" class="space-y-4">
<!-- Observation Info -->
<div class="p-3 bg-slate-800/50 rounded-lg">
<div class="text-xs text-slate-500 uppercase tracking-wide mb-1">Observation</div>
<div class="text-amber-100 font-medium">{{ data.observation.title || 'Untitled' }}</div>
<div class="flex items-center gap-2 mt-1 text-xs text-slate-400">
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded">{{ data.observation.type }}</span>
<span>{{ data.scoring.age_days.toFixed(1) }} days old</span>
</div>
</div>
<!-- Final Score -->
<div class="p-4 bg-gradient-to-r from-purple-500/20 to-indigo-500/10 rounded-lg border border-purple-500/30">
<div class="flex items-center justify-between">
<span class="text-sm text-slate-300">Final Score</span>
<span class="text-2xl font-bold text-purple-300">{{ data.scoring.final_score.toFixed(3) }}</span>
</div>
<div class="mt-2 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-500"
:class="getScoreColor(data.scoring.final_score)"
:style="{ width: getScoreBarWidth(data.scoring.final_score) }"
/>
</div>
</div>
<!-- Score Components -->
<div class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Score Components</div>
<!-- Type Weight -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-tag text-blue-400 w-4" />
<span class="text-slate-300">Type Weight</span>
</div>
<span class="font-mono text-blue-300">{{ data.scoring.type_weight.toFixed(2) }}</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.type_impact }}</p>
<!-- Recency Decay -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-clock text-cyan-400 w-4" />
<span class="text-slate-300">Recency Decay</span>
</div>
<span class="font-mono text-cyan-300">{{ data.scoring.recency_decay.toFixed(2) }}</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.recency_impact }}</p>
<!-- Core Score -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-star text-amber-400 w-4" />
<span class="text-slate-300">Core Score</span>
</div>
<span class="font-mono text-amber-300">{{ data.scoring.core_score.toFixed(3) }}</span>
</div>
<!-- Feedback Contribution -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-thumbs-up text-green-400 w-4" />
<span class="text-slate-300">Feedback</span>
</div>
<span class="font-mono" :class="data.scoring.feedback_contrib >= 0 ? 'text-green-300' : 'text-red-300'">
{{ data.scoring.feedback_contrib >= 0 ? '+' : '' }}{{ data.scoring.feedback_contrib.toFixed(3) }}
</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.feedback_impact }}</p>
<!-- Concept Contribution -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-tags text-purple-400 w-4" />
<span class="text-slate-300">Concepts</span>
</div>
<span class="font-mono text-purple-300">+{{ data.scoring.concept_contrib.toFixed(3) }}</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.concept_impact }}</p>
<!-- Retrieval Contribution -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-search text-indigo-400 w-4" />
<span class="text-slate-300">Retrieval</span>
</div>
<span class="font-mono text-indigo-300">+{{ data.scoring.retrieval_contrib.toFixed(3) }}</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.retrieval_impact }}</p>
</div>
</div>
</Card>
</div>
</div>
</Teleport>
</template>
+271
View File
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { fetchSearchAnalytics, fetchRecentSearches, type SearchAnalytics, type RecentQuery } from '@/utils/api'
import Card from './Card.vue'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const analytics = ref<SearchAnalytics | null>(null)
const recentSearches = ref<RecentQuery[]>([])
const loadData = async () => {
if (!props.show) return
loading.value = true
error.value = null
try {
const [analyticsData, searchesData] = await Promise.all([
fetchSearchAnalytics(),
fetchRecentSearches(20)
])
analytics.value = analyticsData
recentSearches.value = searchesData
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load search analytics'
} finally {
loading.value = false
}
}
// Load on mount and when show changes
onMounted(() => {
if (props.show) loadData()
})
watch(() => props.show, (newVal) => {
if (newVal) loadData()
})
// Computed stats
const cacheHitRate = computed(() => {
if (!analytics.value || analytics.value.total_searches === 0) return 0
return (analytics.value.cache_hits / analytics.value.total_searches) * 100
})
const coalescedRate = computed(() => {
if (!analytics.value || analytics.value.total_searches === 0) return 0
return (analytics.value.coalesced_requests / analytics.value.total_searches) * 100
})
const errorRate = computed(() => {
if (!analytics.value || analytics.value.total_searches === 0) return 0
return (analytics.value.search_errors / analytics.value.total_searches) * 100
})
// Helper for latency color
const getLatencyColor = (ms: number) => {
if (ms < 10) return 'text-green-400'
if (ms < 50) return 'text-amber-400'
return 'text-red-400'
}
// Helper for formatting time ago
const formatTimeAgo = (isoDate: string) => {
const date = new Date(isoDate)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}
</script>
<template>
<!-- Modal Backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal Content -->
<div class="relative w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<Card
gradient="bg-gradient-to-br from-cyan-500/10 to-blue-500/5"
border-class="border-cyan-500/30"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-chart-line text-cyan-400" />
<h3 class="text-lg font-semibold text-cyan-100">Search Analytics</h3>
</div>
<button
@click="emit('close')"
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-2xl text-cyan-400" />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="analytics" class="space-y-6">
<!-- Overview Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Total Searches -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-2xl font-bold text-cyan-300">{{ analytics.total_searches.toLocaleString() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Total Searches</div>
</div>
<!-- Vector Searches -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-2xl font-bold text-purple-300">{{ analytics.vector_searches.toLocaleString() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Vector Searches</div>
</div>
<!-- Filter Searches -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-2xl font-bold text-blue-300">{{ analytics.filter_searches.toLocaleString() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Filter Searches</div>
</div>
<!-- Cache Hits -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-2xl font-bold text-green-300">{{ analytics.cache_hits.toLocaleString() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Cache Hits</div>
</div>
</div>
<!-- Performance Metrics -->
<div class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Performance Metrics</div>
<!-- Cache Hit Rate -->
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fas fa-database text-green-400 w-5" />
<span class="text-slate-300">Cache Hit Rate</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full bg-green-500 transition-all"
:style="{ width: `${cacheHitRate}%` }"
/>
</div>
<span class="font-mono text-green-300 w-16 text-right">{{ cacheHitRate.toFixed(1) }}%</span>
</div>
</div>
<!-- Coalesced Rate -->
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fas fa-compress-arrows-alt text-amber-400 w-5" />
<span class="text-slate-300">Coalesced Requests</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full bg-amber-500 transition-all"
:style="{ width: `${coalescedRate}%` }"
/>
</div>
<span class="font-mono text-amber-300 w-16 text-right">{{ coalescedRate.toFixed(1) }}%</span>
</div>
</div>
<!-- Error Rate -->
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fas fa-exclamation-circle text-red-400 w-5" />
<span class="text-slate-300">Error Rate</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full bg-red-500 transition-all"
:style="{ width: `${Math.min(100, errorRate)}%` }"
/>
</div>
<span class="font-mono text-red-300 w-16 text-right">{{ errorRate.toFixed(2) }}%</span>
</div>
</div>
</div>
<!-- Latency Stats -->
<div class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Latency</div>
<div class="grid grid-cols-3 gap-3">
<!-- Average Latency -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_latency_ms)">
{{ analytics.avg_latency_ms.toFixed(1) }}ms
</div>
<div class="text-xs text-slate-500">Average</div>
</div>
<!-- Vector Latency -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_vector_latency_ms)">
{{ analytics.avg_vector_latency_ms.toFixed(1) }}ms
</div>
<div class="text-xs text-slate-500">Vector</div>
</div>
<!-- Filter Latency -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_filter_latency_ms)">
{{ analytics.avg_filter_latency_ms.toFixed(1) }}ms
</div>
<div class="text-xs text-slate-500">Filter</div>
</div>
</div>
</div>
<!-- Recent Searches -->
<div v-if="recentSearches.length > 0" class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Recent Searches</div>
<div class="space-y-2 max-h-48 overflow-y-auto">
<div
v-for="(search, index) in recentSearches"
:key="index"
class="flex items-center gap-3 p-2 bg-slate-800/30 rounded-lg text-sm"
>
<i class="fas fa-search text-slate-500 text-xs" />
<span class="flex-1 text-slate-300 truncate" :title="search.query">{{ search.query }}</span>
<span v-if="search.project" class="text-xs text-amber-600/80 font-mono">{{ search.project.split('/').pop() }}</span>
<span v-if="search.type" class="text-xs text-cyan-500 bg-cyan-500/10 px-1.5 py-0.5 rounded">{{ search.type }}</span>
<span class="text-xs text-slate-500 font-mono">×{{ search.count }}</span>
<span class="text-xs text-slate-600">{{ formatTimeAgo(search.last_used) }}</span>
</div>
</div>
</div>
</div>
</Card>
</div>
</div>
</Teleport>
</template>
+68 -9
View File
@@ -2,6 +2,9 @@
import { ref, computed } from 'vue'
import type { Stats, SelfCheckResponse } from '@/types'
import ProjectFilter from './ProjectFilter.vue'
import SearchAnalytics from './SearchAnalytics.vue'
import SystemHealthDetails from './SystemHealthDetails.vue'
import TopObservations from './TopObservations.vue'
import { useGraphMetrics } from '@/composables'
const props = defineProps<{
@@ -24,6 +27,15 @@ const metricsExpanded = ref(localStorage.getItem('metrics-expanded') === 'true')
// Graph metrics composable
const { graphStats, vectorMetrics, loading: metricsLoading, refresh: refreshMetrics } = useGraphMetrics()
// Search Analytics modal state
const showSearchAnalytics = ref(false)
// System Health Details modal state
const showHealthDetails = ref(false)
// Top Observations modal state
const showTopObservations = ref(false)
function toggleCollapse() {
isCollapsed.value = !isCollapsed.value
localStorage.setItem('sidebar-collapsed', String(isCollapsed.value))
@@ -106,9 +118,18 @@ function getStatusColor(status: string): string {
<!-- Component Health -->
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<i :class="['fas', overallHealthIcon, overallHealthColor]" />
<h3 class="text-sm font-semibold text-white">System Health</h3>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<i :class="['fas', overallHealthIcon, overallHealthColor]" />
<h3 class="text-sm font-semibold text-white">System Health</h3>
</div>
<button
@click="showHealthDetails = true"
class="text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
title="View detailed health status"
>
<i class="fas fa-expand" />
</button>
</div>
<div v-if="health" class="space-y-2">
@@ -134,9 +155,18 @@ function getStatusColor(status: string): string {
<!-- Memory Stats -->
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<i class="fas fa-brain text-purple-400" />
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<i class="fas fa-brain text-purple-400" />
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
</div>
<button
@click="showTopObservations = true"
class="text-xs text-amber-400 hover:text-amber-300 transition-colors"
title="View top observations"
>
<i class="fas fa-trophy" />
</button>
</div>
<div class="space-y-3">
@@ -171,9 +201,18 @@ function getStatusColor(status: string): string {
<!-- Retrieval Stats -->
<div v-if="stats?.retrieval" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<i class="fas fa-search text-cyan-400" />
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<i class="fas fa-search text-cyan-400" />
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
</div>
<button
@click="showSearchAnalytics = true"
class="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
title="View detailed analytics"
>
<i class="fas fa-chart-line" />
</button>
</div>
<div class="space-y-3">
@@ -373,6 +412,26 @@ function getStatusColor(status: string): string {
<i class="fas fa-chart-line text-violet-400" />
</div>
</div>
<!-- Search Analytics Modal -->
<SearchAnalytics
:show="showSearchAnalytics"
@close="showSearchAnalytics = false"
/>
<!-- System Health Details Modal -->
<SystemHealthDetails
:show="showHealthDetails"
@close="showHealthDetails = false"
/>
<!-- Top Observations Modal -->
<TopObservations
:show="showTopObservations"
:current-project="currentProject"
@close="showTopObservations = false"
@navigate-to-observation="$emit('update:project', null)"
/>
</aside>
</template>
+249
View File
@@ -0,0 +1,249 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { fetchSystemHealth, type SystemHealth } from '@/utils/api'
import Card from './Card.vue'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const health = ref<SystemHealth | null>(null)
const loadData = async () => {
if (!props.show) return
loading.value = true
error.value = null
try {
health.value = await fetchSystemHealth()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load system health'
} finally {
loading.value = false
}
}
// Load on mount and when show changes
onMounted(() => {
if (props.show) loadData()
})
watch(() => props.show, (newVal) => {
if (newVal) loadData()
})
// Status helpers
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy': return 'fa-circle-check'
case 'degraded': return 'fa-triangle-exclamation'
case 'unhealthy': return 'fa-circle-xmark'
default: return 'fa-circle-question'
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy': return 'text-green-400'
case 'degraded': return 'text-amber-400'
case 'unhealthy': return 'text-red-400'
default: return 'text-slate-400'
}
}
const getStatusBgColor = (status: string) => {
switch (status) {
case 'healthy': return 'bg-green-500/20 border-green-500/30'
case 'degraded': return 'bg-amber-500/20 border-amber-500/30'
case 'unhealthy': return 'bg-red-500/20 border-red-500/30'
default: return 'bg-slate-500/20 border-slate-500/30'
}
}
const getLatencyColor = (ms: number | undefined) => {
if (!ms) return 'text-slate-400'
if (ms < 10) return 'text-green-400'
if (ms < 50) return 'text-amber-400'
return 'text-red-400'
}
// Count healthy/degraded/unhealthy components
const componentCounts = computed(() => {
if (!health.value) return { healthy: 0, degraded: 0, unhealthy: 0 }
const counts = { healthy: 0, degraded: 0, unhealthy: 0 }
for (const c of health.value.components) {
if (c.status === 'healthy') counts.healthy++
else if (c.status === 'degraded') counts.degraded++
else if (c.status === 'unhealthy') counts.unhealthy++
}
return counts
})
</script>
<template>
<!-- Modal Backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal Content -->
<div class="relative w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto">
<Card
gradient="bg-gradient-to-br from-emerald-500/10 to-green-500/5"
border-class="border-emerald-500/30"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-heartbeat text-emerald-400" />
<h3 class="text-lg font-semibold text-emerald-100">System Health</h3>
</div>
<button
@click="emit('close')"
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-2xl text-emerald-400" />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="health" class="space-y-5">
<!-- Overall Status -->
<div
class="p-4 rounded-lg border"
:class="getStatusBgColor(health.status)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<i
class="fas text-3xl"
:class="[getStatusIcon(health.status), getStatusColor(health.status)]"
/>
<div>
<div class="text-lg font-semibold capitalize" :class="getStatusColor(health.status)">
{{ health.status }}
</div>
<div class="text-xs text-slate-400">Overall System Status</div>
</div>
</div>
<div class="text-right">
<div class="text-sm text-slate-300 font-mono">{{ health.version }}</div>
<div class="text-xs text-slate-500">Version</div>
</div>
</div>
</div>
<!-- Component Status Summary -->
<div class="grid grid-cols-3 gap-3 text-center">
<div class="p-3 bg-slate-800/50 rounded-lg">
<div class="text-xl font-bold text-green-400">{{ componentCounts.healthy }}</div>
<div class="text-xs text-slate-500">Healthy</div>
</div>
<div class="p-3 bg-slate-800/50 rounded-lg">
<div class="text-xl font-bold text-amber-400">{{ componentCounts.degraded }}</div>
<div class="text-xs text-slate-500">Degraded</div>
</div>
<div class="p-3 bg-slate-800/50 rounded-lg">
<div class="text-xl font-bold text-red-400">{{ componentCounts.unhealthy }}</div>
<div class="text-xs text-slate-500">Unhealthy</div>
</div>
</div>
<!-- Components List -->
<div class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Components</div>
<div class="space-y-2">
<div
v-for="component in health.components"
:key="component.name"
class="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg"
>
<!-- Status Icon -->
<i
class="fas w-5 text-center"
:class="[getStatusIcon(component.status), getStatusColor(component.status)]"
/>
<!-- Name & Message -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-200">{{ component.name }}</div>
<div v-if="component.message" class="text-xs text-slate-500 truncate" :title="component.message">
{{ component.message }}
</div>
</div>
<!-- Latency -->
<div v-if="component.latency_ms !== undefined" class="text-right">
<span class="font-mono text-sm" :class="getLatencyColor(component.latency_ms)">
{{ component.latency_ms.toFixed(1) }}ms
</span>
</div>
<!-- Status Badge -->
<span
class="text-xs font-medium capitalize px-2 py-0.5 rounded"
:class="[getStatusColor(component.status), getStatusBgColor(component.status)]"
>
{{ component.status }}
</span>
</div>
</div>
</div>
<!-- Warnings -->
<div v-if="health.warnings && health.warnings.length > 0" class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Warnings</div>
<div class="space-y-2">
<div
v-for="(warning, index) in health.warnings"
:key="index"
class="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg text-sm"
>
<i class="fas fa-exclamation-triangle text-amber-400 mt-0.5" />
<span class="text-amber-200">{{ warning }}</span>
</div>
</div>
</div>
<!-- Refresh Button -->
<button
@click="loadData"
:disabled="loading"
class="w-full py-2 text-sm text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }" />
Refresh Health Status
</button>
</div>
</Card>
</div>
</div>
</Teleport>
</template>
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { fetchTopObservations, fetchMostRetrievedObservations } from '@/utils/api'
import type { Observation } from '@/types'
import Card from './Card.vue'
const props = defineProps<{
show: boolean
currentProject: string | null
}>()
const emit = defineEmits<{
close: []
navigateToObservation: [id: number]
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const topObservations = ref<Observation[]>([])
const mostRetrieved = ref<Observation[]>([])
const activeTab = ref<'top' | 'retrieved'>('top')
const loadData = async () => {
if (!props.show) return
loading.value = true
error.value = null
try {
const project = props.currentProject || undefined
const [topData, retrievedData] = await Promise.all([
fetchTopObservations(project, 15),
fetchMostRetrievedObservations(project, 15)
])
topObservations.value = topData
mostRetrieved.value = retrievedData
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load observations'
} finally {
loading.value = false
}
}
// Load on mount and when show changes
onMounted(() => {
if (props.show) loadData()
})
watch(() => props.show, (newVal) => {
if (newVal) loadData()
})
// Also reload when project changes
watch(() => props.currentProject, () => {
if (props.show) loadData()
})
// Type config for styling
const typeConfig: Record<string, { icon: string; colorClass: string; bgClass: string }> = {
discovery: { icon: 'fa-lightbulb', colorClass: 'text-amber-400', bgClass: 'bg-amber-500/20' },
bugfix: { icon: 'fa-bug', colorClass: 'text-red-400', bgClass: 'bg-red-500/20' },
change: { icon: 'fa-code-branch', colorClass: 'text-blue-400', bgClass: 'bg-blue-500/20' },
refactor: { icon: 'fa-wrench', colorClass: 'text-purple-400', bgClass: 'bg-purple-500/20' },
feature: { icon: 'fa-star', colorClass: 'text-green-400', bgClass: 'bg-green-500/20' },
pattern: { icon: 'fa-puzzle-piece', colorClass: 'text-cyan-400', bgClass: 'bg-cyan-500/20' },
architecture: { icon: 'fa-sitemap', colorClass: 'text-indigo-400', bgClass: 'bg-indigo-500/20' },
preference: { icon: 'fa-heart', colorClass: 'text-pink-400', bgClass: 'bg-pink-500/20' }
}
const getTypeConfig = (type: string) => {
return typeConfig[type] || { icon: 'fa-circle', colorClass: 'text-slate-400', bgClass: 'bg-slate-500/20' }
}
// Format score for display
const formatScore = (score: number) => {
return score.toFixed(2)
}
// Current observations based on active tab
const currentObservations = () => {
return activeTab.value === 'top' ? topObservations.value : mostRetrieved.value
}
</script>
<template>
<!-- Modal Backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal Content -->
<div class="relative w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<Card
gradient="bg-gradient-to-br from-amber-500/10 to-orange-500/5"
border-class="border-amber-500/30"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-trophy text-amber-400" />
<h3 class="text-lg font-semibold text-amber-100">Top Observations</h3>
</div>
<button
@click="emit('close')"
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
<!-- Tabs -->
<div class="flex gap-2 mb-4">
<button
@click="activeTab = 'top'"
:class="[
'flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors',
activeTab === 'top'
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
]"
>
<i class="fas fa-star mr-2" />
Highest Scored
</button>
<button
@click="activeTab = 'retrieved'"
:class="[
'flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors',
activeTab === 'retrieved'
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
]"
>
<i class="fas fa-search mr-2" />
Most Retrieved
</button>
</div>
<!-- Project Filter Indicator -->
<div v-if="currentProject" class="flex items-center gap-2 mb-4 text-xs text-slate-500">
<i class="fas fa-filter" />
<span>Filtered by:</span>
<span class="text-amber-600/80 font-mono">{{ currentProject.split('/').pop() }}</span>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-2xl text-amber-400" />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
</div>
<!-- Empty State -->
<div v-else-if="currentObservations().length === 0" class="text-center py-8">
<i class="fas fa-inbox text-2xl text-slate-500 mb-2" />
<p class="text-slate-400">No observations found</p>
</div>
<!-- Content -->
<div v-else class="space-y-2">
<div
v-for="(obs, index) in currentObservations()"
:key="obs.id"
@click="emit('navigateToObservation', obs.id); emit('close')"
class="flex items-center gap-3 p-3 bg-slate-800/30 hover:bg-slate-800/50 rounded-lg cursor-pointer transition-colors group"
>
<!-- Rank -->
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
:class="[
index < 3 ? 'bg-amber-500/30 text-amber-300' : 'bg-slate-700/50 text-slate-400'
]"
>
{{ index + 1 }}
</div>
<!-- Type Icon -->
<div
class="w-8 h-8 rounded-lg flex items-center justify-center"
:class="getTypeConfig(obs.type).bgClass"
>
<i
class="fas text-sm"
:class="[getTypeConfig(obs.type).icon, getTypeConfig(obs.type).colorClass]"
/>
</div>
<!-- Title & Meta -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-200 truncate group-hover:text-amber-200 transition-colors">
{{ obs.title || 'Untitled' }}
</div>
<div class="flex items-center gap-2 text-xs text-slate-500">
<span class="capitalize">{{ obs.type }}</span>
<span v-if="obs.project" class="text-amber-600/70 font-mono">{{ obs.project.split('/').pop() }}</span>
</div>
</div>
<!-- Score / Retrieval Count -->
<div class="text-right flex-shrink-0">
<div
v-if="activeTab === 'top'"
class="text-sm font-mono font-bold"
:class="obs.importance_score && obs.importance_score >= 1.5 ? 'text-green-400' : obs.importance_score && obs.importance_score >= 1 ? 'text-amber-400' : 'text-slate-400'"
>
{{ formatScore(obs.importance_score || 1) }}
</div>
<div
v-else
class="text-sm font-mono font-bold text-cyan-400"
>
{{ obs.retrieval_count || 0 }}×
</div>
<div class="text-xs text-slate-500">
{{ activeTab === 'top' ? 'score' : 'retrieved' }}
</div>
</div>
<!-- Arrow -->
<i class="fas fa-chevron-right text-slate-600 group-hover:text-slate-400 transition-colors" />
</div>
</div>
<!-- Refresh Button -->
<button
@click="loadData"
:disabled="loading"
class="w-full mt-4 py-2 text-sm text-amber-400 hover:text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }" />
Refresh
</button>
</Card>
</div>
</div>
</Teleport>
</template>
+1
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/main.css'
import '@fortawesome/fontawesome-free/css/all.min.css'
createApp(App).mount('#app')
+126 -1
View File
@@ -89,10 +89,19 @@ async function fetchWithRetry<T>(url: string, options: FetchOptions = {}): Promi
throw lastError!
}
interface ObservationsResponse {
observations: Observation[]
total: number
limit: number
offset: number
hasMore: boolean
}
export async function fetchObservations(limit: number = 100, project?: string, signal?: AbortSignal): Promise<Observation[]> {
const params = new URLSearchParams({ limit: String(limit) })
if (project) params.append('project', project)
return fetchWithRetry<Observation[]>(`${API_BASE}/observations?${params}`, { signal })
const response = await fetchWithRetry<ObservationsResponse>(`${API_BASE}/observations?${params}`, { signal })
return response.observations || []
}
export async function fetchPrompts(limit: number = 100, project?: string, signal?: AbortSignal): Promise<UserPrompt[]> {
@@ -165,6 +174,122 @@ export async function fetchRelationStats(signal?: AbortSignal): Promise<Relation
return fetchWithRetry<RelationStats>(`${API_BASE}/relations/stats`, { signal })
}
// Scoring API functions
export interface ScoreBreakdown {
observation: {
id: number
title: string
type: string
project: string
created_at: number
}
scoring: {
final_score: number
type_weight: number
recency_decay: number
core_score: number
feedback_contrib: number
concept_contrib: number
retrieval_contrib: number
age_days: number
}
explanation: {
type_impact: string
recency_impact: string
feedback_impact: string
concept_impact: string
retrieval_impact: string
}
}
export async function fetchObservationScore(observationId: number, signal?: AbortSignal): Promise<ScoreBreakdown> {
return fetchWithRetry<ScoreBreakdown>(`${API_BASE}/observations/${observationId}/score`, { signal })
}
export interface FeedbackStats {
total: number
positive: number
negative: number
neutral: number
avg_score: number
avg_retrieval: number
}
export interface TopObservation {
id: number
title: string
type: string
importance_score: number
retrieval_count?: number
}
export async function fetchScoringStats(project?: string, signal?: AbortSignal): Promise<FeedbackStats> {
const params = new URLSearchParams()
if (project) params.append('project', project)
const query = params.toString()
return fetchWithRetry<FeedbackStats>(`${API_BASE}/scoring/stats${query ? '?' + query : ''}`, { signal })
}
export async function fetchTopObservations(project?: string, limit: number = 10, signal?: AbortSignal): Promise<Observation[]> {
const params = new URLSearchParams({ limit: String(limit) })
if (project) params.append('project', project)
return fetchWithRetry<Observation[]>(`${API_BASE}/observations/top?${params}`, { signal })
}
export async function fetchMostRetrievedObservations(project?: string, limit: number = 10, signal?: AbortSignal): Promise<Observation[]> {
const params = new URLSearchParams({ limit: String(limit) })
if (project) params.append('project', project)
return fetchWithRetry<Observation[]>(`${API_BASE}/observations/most-retrieved?${params}`, { signal })
}
// Search Analytics API functions
export interface RecentQuery {
query: string
project?: string
type?: string
count: number
last_used: string
}
export interface SearchAnalytics {
total_searches: number
vector_searches: number
filter_searches: number
cache_hits: number
coalesced_requests: number
search_errors: number
avg_latency_ms: number
avg_vector_latency_ms: number
avg_filter_latency_ms: number
}
export async function fetchSearchAnalytics(signal?: AbortSignal): Promise<SearchAnalytics> {
return fetchWithRetry<SearchAnalytics>(`${API_BASE}/search/analytics`, { signal })
}
export async function fetchRecentSearches(limit: number = 20, signal?: AbortSignal): Promise<RecentQuery[]> {
return fetchWithRetry<RecentQuery[]>(`${API_BASE}/search/recent?limit=${limit}`, { signal })
}
// System health API
export interface ComponentHealth {
name: string
status: 'healthy' | 'degraded' | 'unhealthy'
message?: string
latency_ms?: number
}
export interface SystemHealth {
status: 'healthy' | 'degraded' | 'unhealthy'
version: string
components: ComponentHealth[]
warnings?: string[]
}
export async function fetchSystemHealth(signal?: AbortSignal): Promise<SystemHealth> {
return fetchWithRetry<SystemHealth>(`${API_BASE}/selfcheck`, { signal })
}
export async function fetchGraphStats(signal?: AbortSignal): Promise<GraphStats> {
return fetchWithRetry<GraphStats>(`${API_BASE}/graph/stats`, { signal })
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usegraphmetrics.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/usetypes.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/relation.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/relationgraph.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"}
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usegraphmetrics.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/usetypes.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/relation.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/relationgraph.vue","./src/components/scorebreakdown.vue","./src/components/searchanalytics.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/systemhealthdetails.vue","./src/components/timeline.vue","./src/components/topobservations.vue"],"version":"5.7.3"}