mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcab3ebef4 | ||
|
|
a1d4459329 | ||
|
|
5a81f37779 | ||
|
|
11fd1969de | ||
|
|
cd00f74e2c | ||
|
|
319f2ca28b | ||
|
|
77f5f02510 | ||
|
|
1a6f6b6e5e | ||
|
|
b0c0b4208e | ||
|
|
84aff03d7c | ||
|
|
9432958b03 | ||
|
|
1c63843b6f | ||
|
|
76b1ec3c9f | ||
|
|
968fd2293e | ||
|
|
c123568dfd | ||
|
|
d69511dd9b | ||
|
|
617df091aa | ||
|
|
9e4a001e7e | ||
|
|
5335a8a7a6 |
@@ -108,6 +108,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 +138,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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{}{
|
||||
|
||||
@@ -16,6 +16,10 @@ type Input struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if !hooks.IsWorkerAvailable() {
|
||||
hooks.WriteResponse("SubagentStop", true)
|
||||
return
|
||||
}
|
||||
hooks.RunHook("SubagentStop", handleSubagentStop)
|
||||
}
|
||||
|
||||
|
||||
+122
-43
@@ -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
-86
@@ -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,69 +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
|
||||
// Note: maintenanceService is nil because it runs in the worker process
|
||||
server := mcp.NewServer(
|
||||
searchMgr,
|
||||
Version,
|
||||
observationStore,
|
||||
patternStore,
|
||||
relationStore,
|
||||
sessionStore,
|
||||
vectorClient,
|
||||
scoreCalculator,
|
||||
recalculator,
|
||||
nil, // maintenanceService - handled by worker
|
||||
)
|
||||
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")
|
||||
@@ -143,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() {
|
||||
|
||||
@@ -7,15 +7,15 @@ 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/mattn/go-sqlite3 v1.14.34
|
||||
github.com/rs/zerolog v1.34.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.19.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -34,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.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -10,8 +10,8 @@ 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=
|
||||
@@ -28,8 +28,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
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.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/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=
|
||||
@@ -54,15 +54,17 @@ github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89
|
||||
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=
|
||||
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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=
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -52,6 +53,7 @@ type Config struct {
|
||||
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"`
|
||||
ContextMaxPromptResults int `json:"context_max_prompt_results"`
|
||||
@@ -64,10 +66,13 @@ type Config struct {
|
||||
HubThreshold int `json:"hub_threshold"`
|
||||
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"`
|
||||
@@ -168,6 +173,10 @@ func Default() *Config {
|
||||
ContextObsConcepts: DefaultObservationConcepts,
|
||||
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)
|
||||
@@ -269,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+866
-2230
File diff suppressed because it is too large
Load Diff
+1960
-243
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package pattern
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -370,12 +371,16 @@ func (d *Detector) CandidateCount() int {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+48
-11
@@ -13,6 +13,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -142,19 +143,55 @@ func formatWarning(format string, args ...any) string {
|
||||
}
|
||||
|
||||
// handleHealth handles health check requests.
|
||||
// Returns 200 OK immediately (even during init) so hooks can connect quickly.
|
||||
// Use /api/ready for full readiness check.
|
||||
// Returns 200 when ready, 503 when initializing or degraded.
|
||||
func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := "starting"
|
||||
if s.ready.Load() {
|
||||
status = "ready"
|
||||
} else if err := s.GetInitError(); err != nil {
|
||||
status = "error"
|
||||
status := "ready"
|
||||
dbStatus := "ok"
|
||||
embeddingStatus := "ok"
|
||||
|
||||
if !s.ready.Load() {
|
||||
status = "initializing"
|
||||
if err := s.GetInitError(); err != nil {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"status": status,
|
||||
"version": s.version,
|
||||
})
|
||||
|
||||
// Check embedding service
|
||||
if s.embedSvc == nil {
|
||||
embeddingStatus = "unavailable"
|
||||
if status == "ready" {
|
||||
status = "degraded"
|
||||
}
|
||||
}
|
||||
|
||||
// Check DB
|
||||
if s.store == nil {
|
||||
dbStatus = "unavailable"
|
||||
if status == "ready" {
|
||||
status = "degraded"
|
||||
}
|
||||
}
|
||||
|
||||
activeSessions := 0
|
||||
if s.sessionManager != nil {
|
||||
activeSessions = s.sessionManager.GetActiveSessionCount()
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"status": status,
|
||||
"ready": s.ready.Load(),
|
||||
"uptime_seconds": int(time.Since(s.startTime).Seconds()),
|
||||
"active_sessions": activeSessions,
|
||||
"db_status": dbStatus,
|
||||
"embedding_status": embeddingStatus,
|
||||
"version": s.version,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if status != "ready" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// handleVersion returns the worker version for version checking.
|
||||
|
||||
@@ -593,3 +593,118 @@ func (s *Service) handleGetObservationByID(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,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)
|
||||
@@ -95,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)
|
||||
@@ -130,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)
|
||||
@@ -158,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)
|
||||
@@ -191,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)
|
||||
@@ -245,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)
|
||||
@@ -279,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)
|
||||
@@ -300,19 +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.Warn().Err(err).Msg("Background score recalculation failed")
|
||||
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,27 +331,24 @@ 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
|
||||
// Use service context to respect shutdown signals
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(s.ctx, 3*time.Second)
|
||||
// Create a new context with timeout for the background operation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := store.IncrementRetrievalCount(ctx, ids); err != nil {
|
||||
// Log but don't fail - this is a background operation
|
||||
if s.ctx.Err() == nil { // Don't log during shutdown
|
||||
log.Debug().Err(err).Msg("Failed to increment retrieval counts")
|
||||
}
|
||||
_ = err // Explicitly ignore - background operation
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -459,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) {
|
||||
@@ -2028,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.
|
||||
@@ -2399,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.
|
||||
@@ -2894,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.
|
||||
|
||||
@@ -4,6 +4,7 @@ package worker
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -13,7 +14,14 @@ import (
|
||||
func (s *Service) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := s.updater.CheckForUpdate(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
// 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -194,6 +195,36 @@ func hashRequest(toolName, input, output string) string {
|
||||
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]any)
|
||||
|
||||
@@ -212,6 +243,7 @@ const MaxVectorSyncWorkers = 8
|
||||
type Processor struct {
|
||||
observationStore *gorm.ObservationStore
|
||||
summaryStore *gorm.SummaryStore
|
||||
vectorClient *sqlitevec.Client
|
||||
broadcastFunc BroadcastFunc
|
||||
syncObservationFunc SyncObservationFunc
|
||||
syncSummaryFunc SyncSummaryFunc
|
||||
@@ -240,6 +272,11 @@ 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]any) {
|
||||
if p.broadcastFunc != nil {
|
||||
@@ -429,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")
|
||||
@@ -644,10 +699,11 @@ func (p *Processor) callClaudeCLI(ctx context.Context, prompt string) (string, e
|
||||
// Disable any plugin hooks by setting an env var that our hooks can check
|
||||
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()
|
||||
|
||||
@@ -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"
|
||||
@@ -1178,3 +1181,663 @@ func TestSafeResolvePath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +43,13 @@ const (
|
||||
// QueueProcessInterval is how often the background queue processor runs.
|
||||
QueueProcessInterval = 2 * time.Second
|
||||
|
||||
// reinitializationDrainDelay is the delay after marking the service as not ready
|
||||
// to allow in-flight requests to complete before reinitializing.
|
||||
reinitializationDrainDelay = 200 * time.Millisecond
|
||||
|
||||
// MaxConcurrentProcessing limits the number of concurrent session processing goroutines.
|
||||
MaxConcurrentProcessing = 4
|
||||
|
||||
// VectorSyncMaxRetries is the maximum number of retries for vector sync operations.
|
||||
VectorSyncMaxRetries = 3
|
||||
|
||||
@@ -138,6 +145,7 @@ type Service struct {
|
||||
updater *update.Updater
|
||||
rateLimiter *PerClientRateLimiter
|
||||
expensiveOpLimiter *ExpensiveOperationLimiter
|
||||
contextCache sync.Map
|
||||
version string
|
||||
recentQueriesBuf [maxRecentQueries]RecentSearchQuery
|
||||
wg sync.WaitGroup
|
||||
@@ -178,6 +186,13 @@ type staleVerifyRequest struct {
|
||||
observationID int64
|
||||
}
|
||||
|
||||
// contextCacheEntry caches clustering results for context injection.
|
||||
type contextCacheEntry struct {
|
||||
timestamp time.Time
|
||||
observations []*models.Observation
|
||||
obsCount int
|
||||
}
|
||||
|
||||
// RecentSearchQuery tracks a search query for analytics.
|
||||
type RecentSearchQuery struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
@@ -288,6 +303,11 @@ func (s *Service) setupVectorSyncCallbacks(
|
||||
})
|
||||
}
|
||||
|
||||
// Set vector client on processor for write-time deduplication
|
||||
if processor != nil && s.vectorClient != nil {
|
||||
processor.SetVectorClient(s.vectorClient)
|
||||
}
|
||||
|
||||
// Set cleanup callback on observation store to sync deletes to vector store
|
||||
if observationStore != nil && vectorSync != nil {
|
||||
observationStore.SetCleanupFunc(func(ctx context.Context, deletedIDs []int64) {
|
||||
@@ -614,6 +634,7 @@ func (s *Service) startWatchers() {
|
||||
func (s *Service) reinitializeDatabase() {
|
||||
// Block new requests
|
||||
s.ready.Store(false)
|
||||
time.Sleep(reinitializationDrainDelay) // Allow in-flight requests to complete
|
||||
log.Info().Msg("Database reinitialization starting...")
|
||||
|
||||
// Get old store references
|
||||
@@ -1198,6 +1219,8 @@ func (s *Service) setupRoutes() {
|
||||
// Vector management endpoints
|
||||
s.router.Post("/api/vectors/rebuild", s.handleTriggerVectorRebuild)
|
||||
s.router.Get("/api/vectors/health", s.handleVectorHealth)
|
||||
s.router.Get("/api/vector/metrics", s.handleVectorMetrics)
|
||||
s.router.Get("/api/graph/stats", s.handleGraphStats)
|
||||
|
||||
// Readiness check - returns 200 only when fully initialized
|
||||
s.router.Get("/api/ready", s.handleReady)
|
||||
@@ -1585,12 +1608,13 @@ func (s *Service) processQueue() {
|
||||
|
||||
// processAllSessions processes pending messages for all active sessions.
|
||||
// Messages are processed in parallel using goroutines, with concurrency
|
||||
// limited by the processor's semaphore.
|
||||
// limited by a channel-based semaphore.
|
||||
func (s *Service) processAllSessions() {
|
||||
// Get all sessions with pending messages
|
||||
sessions := s.sessionManager.GetAllSessions()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, MaxConcurrentProcessing)
|
||||
|
||||
for _, sess := range sessions {
|
||||
// Get pending messages
|
||||
@@ -1599,11 +1623,13 @@ func (s *Service) processAllSessions() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process each message in a goroutine
|
||||
// Process each message in a goroutine with semaphore
|
||||
for _, msg := range messages {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{} // Acquire semaphore slot
|
||||
go func(sess *session.ActiveSession, msg session.PendingMessage) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }() // Release semaphore slot
|
||||
|
||||
switch msg.Type {
|
||||
case session.MessageTypeObservation:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-10
@@ -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.13",
|
||||
"version": "0.11.47",
|
||||
"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.13",
|
||||
"latest": "0.11.47",
|
||||
"versions": {
|
||||
"0.11.13": {
|
||||
"releaseDate": "2026-01-11",
|
||||
"0.11.47": {
|
||||
"releaseDate": "2026-03-06",
|
||||
"downloads": {
|
||||
"darwin-arm64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.13/claude-mnemonic_0.11.13_darwin_arm64.tar.gz",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.47/claude-mnemonic_0.11.47_darwin_arm64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "4d58f8aa30f7974e80bffe00cac4099ccdf4441dcd9b5b54e3667c3aafe2faa9"
|
||||
"sha256": "486ce84e6c5d0dc21f3516cd0f69dae510b74c100aba921eef232dd2b0040bd8"
|
||||
},
|
||||
"linux-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.13/claude-mnemonic_0.11.13_linux_amd64.tar.gz",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.47/claude-mnemonic_0.11.47_linux_amd64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "24e5469a732ee7d9453062dbaf725fa9fa60388ef04af2239bef074a2000b0c3"
|
||||
"sha256": "fe1692b789977f5466995ebdd8f2ee1ef3eed01c1121f6f17d8350baaf247dac"
|
||||
},
|
||||
"windows-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.13/claude-mnemonic_0.11.13_windows_amd64.zip",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.47/claude-mnemonic_0.11.47_windows_amd64.zip",
|
||||
"format": "zip",
|
||||
"sha256": "9cc3557622f996e7d532fae99930c87a8ed585f8b0a5aa44090241f14d2254c6"
|
||||
"sha256": "507de7f2dd07394e06676ff0e569ce2c359d9faa7c5a222335be0b66c7e68ec7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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)
|
||||
}
|
||||
|
||||
+242
-15
@@ -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 ""
|
||||
@@ -243,7 +445,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 +453,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 +493,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
+55
-13
@@ -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"
|
||||
|
||||
@@ -42,6 +71,24 @@ fi
|
||||
|
||||
# Check if jq is available
|
||||
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 "ERROR: jq 1.6+ is required (found jq-$JQ_VERSION)"
|
||||
echo "Please upgrade jq: brew install jq (macOS) or apt-get install jq (Linux)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
# Ensure cache directory exists and copy plugin files
|
||||
mkdir -p "$CACHE_PATH/.claude-plugin"
|
||||
mkdir -p "$CACHE_PATH/hooks"
|
||||
@@ -64,9 +111,8 @@ 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"
|
||||
|
||||
@@ -82,9 +128,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,9 +147,8 @@ 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"
|
||||
|
||||
@@ -126,13 +170,11 @@ EOF
|
||||
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"
|
||||
if safe_jq_write --arg key "claude-mnemonic" --argjson entry "$MCP_ENTRY" \
|
||||
'.mcpServers //= {} | .mcpServers[$key] = $entry' "$SETTINGS_FILE"; then
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-mnemonic-dashboard",
|
||||
"version": "v0.10.5-1-g7ab4b07-dirty",
|
||||
"version": "v0.11.43-2-g11fd196-dirty",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-mnemonic-dashboard",
|
||||
"version": "v0.10.5-1-g7ab4b07-dirty",
|
||||
"version": "v0.11.43-2-g11fd196-dirty",
|
||||
"dependencies": {
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-network": "^9.1.9",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mnemonic-dashboard",
|
||||
"version": "v0.10.5-1-g7ab4b07-dirty",
|
||||
"version": "v0.11.43-2-g11fd196-dirty",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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')
|
||||
|
||||
+10
-1
@@ -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[]> {
|
||||
|
||||
Reference in New Issue
Block a user