Make things 'betterer' across the board

This commit is contained in:
2026-01-11 00:53:44 +00:00
parent 7ab4b07cf2
commit 548b27702e
47 changed files with 12533 additions and 1782 deletions
+2
View File
@@ -122,6 +122,7 @@ func main() {
startWatchers(ctx, dbPath) startWatchers(ctx, dbPath)
// Create and run MCP server with all dependencies // Create and run MCP server with all dependencies
// Note: maintenanceService is nil because it runs in the worker process
server := mcp.NewServer( server := mcp.NewServer(
searchMgr, searchMgr,
Version, Version,
@@ -132,6 +133,7 @@ func main() {
vectorClient, vectorClient,
scoreCalculator, scoreCalculator,
recalculator, recalculator,
nil, // maintenanceService - handled by worker
) )
log.Info().Str("project", *project).Str("version", Version).Msg("Starting MCP server") log.Info().Str("project", *project).Str("version", Version).Msg("Starting MCP server")
+1
View File
@@ -15,6 +15,7 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/sugarme/tokenizer v0.3.0 github.com/sugarme/tokenizer v0.3.0
github.com/yalue/onnxruntime_go v1.25.0 github.com/yalue/onnxruntime_go v1.25.0
golang.org/x/sync v0.19.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
+2
View File
@@ -52,6 +52,8 @@ 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/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 h1:nlhVau1BpLZ/BYr+WpPZCJRD/WES0qo6dK7aKyyAs3g=
github.com/yalue/onnxruntime_go v1.25.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= github.com/yalue/onnxruntime_go v1.25.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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+14 -3
View File
@@ -71,6 +71,12 @@ type Config struct {
ContextObsConcepts []string `json:"context_obs_concepts"` ContextObsConcepts []string `json:"context_obs_concepts"`
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"` // 0.0-1.0, minimum similarity for inclusion ContextRelevanceThreshold float64 `json:"context_relevance_threshold"` // 0.0-1.0, minimum similarity for inclusion
ContextMaxPromptResults int `json:"context_max_prompt_results"` // Max results per prompt (0 = threshold only) ContextMaxPromptResults int `json:"context_max_prompt_results"` // Max results per prompt (0 = threshold only)
// Maintenance settings
MaintenanceEnabled bool `json:"maintenance_enabled"` // Enable scheduled maintenance
MaintenanceIntervalHours int `json:"maintenance_interval_hours"` // How often to run maintenance (default 6 hours)
ObservationRetentionDays int `json:"observation_retention_days"` // Delete observations older than N days (0 = no age-based deletion)
CleanupStaleObservations bool `json:"cleanup_stale_observations"` // Auto-cleanup stale observations during maintenance
} }
var ( var (
@@ -96,8 +102,9 @@ func SettingsPath() string {
} }
// EnsureDataDir creates the data directory if it doesn't exist. // EnsureDataDir creates the data directory if it doesn't exist.
// Uses 0700 permissions (owner-only) for security.
func EnsureDataDir() error { func EnsureDataDir() error {
return os.MkdirAll(DataDir(), 0750) return os.MkdirAll(DataDir(), 0700)
} }
// EnsureSettings creates a default settings file if it doesn't exist. // EnsureSettings creates a default settings file if it doesn't exist.
@@ -157,8 +164,12 @@ func Default() *Config {
ContextShowLastSummary: true, ContextShowLastSummary: true,
ContextObsTypes: DefaultObservationTypes, ContextObsTypes: DefaultObservationTypes,
ContextObsConcepts: DefaultObservationConcepts, ContextObsConcepts: DefaultObservationConcepts,
ContextRelevanceThreshold: 0.3, // Minimum 30% similarity to include ContextRelevanceThreshold: 0.3, // Minimum 30% similarity to include
ContextMaxPromptResults: 10, // Cap at 10 results max (0 = no cap, threshold only) ContextMaxPromptResults: 10, // Cap at 10 results max (0 = no cap, threshold only)
MaintenanceEnabled: true, // Enable scheduled maintenance
MaintenanceIntervalHours: 6, // Run every 6 hours
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
CleanupStaleObservations: false, // Don't auto-cleanup stale observations
} }
} }
+54 -17
View File
@@ -9,27 +9,13 @@ import (
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// EnsureSessionExists creates a session if it doesn't exist. // EnsureSessionExists creates a session if it doesn't exist.
// Uses INSERT OR IGNORE pattern for atomic idempotent creation (single query instead of COUNT + INSERT).
// This is shared between stores to avoid duplication. // This is shared between stores to avoid duplication.
func EnsureSessionExists(ctx context.Context, db *gorm.DB, sdkSessionID, project string) error { func EnsureSessionExists(ctx context.Context, db *gorm.DB, sdkSessionID, project string) error {
// Check if session exists
var count int64
err := db.WithContext(ctx).
Model(&SDKSession{}).
Where("sdk_session_id = ?", sdkSessionID).
Count(&count).Error
if err != nil {
return err
}
if count > 0 {
return nil // Session exists
}
// Auto-create session
now := time.Now() now := time.Now()
session := &SDKSession{ session := &SDKSession{
ClaudeSessionID: sdkSessionID, ClaudeSessionID: sdkSessionID,
@@ -41,7 +27,14 @@ func EnsureSessionExists(ctx context.Context, db *gorm.DB, sdkSessionID, project
PromptCounter: 0, PromptCounter: 0,
} }
return db.WithContext(ctx).Create(session).Error // Use INSERT OR IGNORE - single query, atomic operation
// If session already exists (conflict on sdk_session_id), do nothing
return db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sdk_session_id"}},
DoNothing: true,
}).
Create(session).Error
} }
// sqlNullString creates a sql.NullString from a string. // sqlNullString creates a sql.NullString from a string.
@@ -52,8 +45,13 @@ func sqlNullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: true} return sql.NullString{String: s, Valid: true}
} }
// MaxPaginationLimit is the maximum allowed limit for pagination queries.
// This protects against resource exhaustion from excessively large requests.
const MaxPaginationLimit = 1000
// ParseLimitParam parses the "limit" query parameter from an HTTP request. // ParseLimitParam parses the "limit" query parameter from an HTTP request.
// Returns defaultLimit if the parameter is missing or invalid. // Returns defaultLimit if the parameter is missing or invalid.
// Note: This does NOT enforce a maximum limit. Use ParseLimitParamWithMax for that.
func ParseLimitParam(r *http.Request, defaultLimit int) int { func ParseLimitParam(r *http.Request, defaultLimit int) int {
if l := r.URL.Query().Get("limit"); l != "" { if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
@@ -62,3 +60,42 @@ func ParseLimitParam(r *http.Request, defaultLimit int) int {
} }
return defaultLimit return defaultLimit
} }
// ParseLimitParamWithMax parses the "limit" query parameter with a maximum cap.
// Returns min(parsed, maxLimit) or defaultLimit if missing/invalid.
// If maxLimit is 0, uses MaxPaginationLimit (1000).
func ParseLimitParamWithMax(r *http.Request, defaultLimit, maxLimit int) int {
if maxLimit <= 0 {
maxLimit = MaxPaginationLimit
}
limit := ParseLimitParam(r, defaultLimit)
if limit > maxLimit {
return maxLimit
}
return limit
}
// ParseOffsetParam parses the "offset" query parameter from an HTTP request.
// Returns 0 if the parameter is missing or invalid.
func ParseOffsetParam(r *http.Request) int {
if o := r.URL.Query().Get("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
return parsed
}
}
return 0
}
// PaginationParams holds pagination parameters.
type PaginationParams struct {
Limit int
Offset int
}
// ParsePaginationParams parses both limit and offset from an HTTP request.
func ParsePaginationParams(r *http.Request, defaultLimit int) PaginationParams {
return PaginationParams{
Limit: ParseLimitParam(r, defaultLimit),
Offset: ParseOffsetParam(r),
}
}
+238
View File
@@ -322,6 +322,244 @@ func runMigrations(db *gorm.DB, sqlDB *sql.DB) error {
return tx.Migrator().DropTable("observation_relations") return tx.Migrator().DropTable("observation_relations")
}, },
}, },
// Migration 012: Query optimization indexes
// Adds covering and composite indexes for common query patterns
{
ID: "012_query_optimization_indexes",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Composite index for observation queries by project + scope + importance
// Covers the common pattern: WHERE project = ? OR scope = 'global' ORDER BY importance_score DESC
`CREATE INDEX IF NOT EXISTS idx_observations_project_scope_importance
ON observations(project, scope, importance_score DESC, created_at_epoch DESC)`,
// Covering index for observation retrieval (includes most used columns)
// Allows index-only scans for listing queries
`CREATE INDEX IF NOT EXISTS idx_observations_project_covering
ON observations(project, scope, is_superseded, importance_score DESC)
WHERE is_superseded = 0 OR is_superseded IS NULL`,
// Index for session summary lookups
`CREATE INDEX IF NOT EXISTS idx_summaries_project_importance
ON session_summaries(project, importance_score DESC, created_at_epoch DESC)`,
// Index for prompt retrieval by session
`CREATE INDEX IF NOT EXISTS idx_prompts_session_number
ON user_prompts(claude_session_id, prompt_number)`,
// Index for pattern queries by frequency
`CREATE INDEX IF NOT EXISTS idx_patterns_frequency
ON patterns(frequency DESC, last_seen_at_epoch DESC)
WHERE is_deprecated = 0`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: index may already exist or fail for benign reasons
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
sqls := []string{
"DROP INDEX IF EXISTS idx_observations_project_scope_importance",
"DROP INDEX IF EXISTS idx_observations_project_covering",
"DROP INDEX IF EXISTS idx_summaries_project_importance",
"DROP INDEX IF EXISTS idx_prompts_session_number",
"DROP INDEX IF EXISTS idx_patterns_frequency",
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
continue
}
}
return nil
},
},
// Migration 013: Add archival columns to observations
{
ID: "013_observation_archival",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Add archival columns
`ALTER TABLE observations ADD COLUMN is_archived INTEGER DEFAULT 0`,
`ALTER TABLE observations ADD COLUMN archived_at_epoch INTEGER`,
`ALTER TABLE observations ADD COLUMN archived_reason TEXT`,
// Index for archived observations
`CREATE INDEX IF NOT EXISTS idx_observations_archived ON observations(is_archived)`,
// Composite index for filtering active (non-archived) observations
`CREATE INDEX IF NOT EXISTS idx_observations_active
ON observations(project, is_archived, is_superseded, importance_score DESC)
WHERE (is_archived = 0 OR is_archived IS NULL)`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: column may already exist
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// SQLite doesn't support DROP COLUMN in older versions
// but for newer versions we can try
sqls := []string{
"DROP INDEX IF EXISTS idx_observations_active",
"DROP INDEX IF EXISTS idx_observations_archived",
}
for _, s := range sqls {
_ = tx.Exec(s).Error
}
return nil
},
},
// Migration 014: Add performance-critical indexes for common query patterns
{
ID: "014_performance_indexes",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Index for batch ID lookups (IN queries)
`CREATE INDEX IF NOT EXISTS idx_observations_id_covering
ON observations(id, project, scope, importance_score)`,
// Index for vector search result fetching
`CREATE INDEX IF NOT EXISTS idx_vectors_doc_type_project
ON vectors(doc_type, project, scope)`,
// Index for session summaries by project
`CREATE INDEX IF NOT EXISTS idx_summaries_project_created
ON session_summaries(project, created_at_epoch DESC)`,
// Index for user prompts by session
`CREATE INDEX IF NOT EXISTS idx_prompts_session_created
ON user_prompts(claude_session_id, created_at_epoch DESC)`,
// Index for patterns by type and project
`CREATE INDEX IF NOT EXISTS idx_patterns_type_project
ON patterns(type, project, frequency DESC)
WHERE is_deprecated = 0`,
// Index for observation relations
`CREATE INDEX IF NOT EXISTS idx_relations_source_type
ON observation_relations(source_observation_id, relation_type)`,
`CREATE INDEX IF NOT EXISTS idx_relations_target_type
ON observation_relations(target_observation_id, relation_type)`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: index may already exist
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
sqls := []string{
"DROP INDEX IF EXISTS idx_observations_id_covering",
"DROP INDEX IF EXISTS idx_vectors_doc_type_project",
"DROP INDEX IF EXISTS idx_summaries_project_created",
"DROP INDEX IF EXISTS idx_prompts_session_created",
"DROP INDEX IF EXISTS idx_patterns_type_project",
"DROP INDEX IF EXISTS idx_relations_source_type",
"DROP INDEX IF EXISTS idx_relations_target_type",
}
for _, s := range sqls {
_ = tx.Exec(s).Error
}
return nil
},
},
// Migration 015: Add optimized composite indexes for common query patterns
{
ID: "015_optimized_composite_indexes",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Composite index for GetRecentObservations with project+scope filtering
// Covers: WHERE (project = ? OR scope = 'global') ORDER BY importance_score DESC, created_at_epoch DESC
`CREATE INDEX IF NOT EXISTS idx_observations_project_scope_created
ON observations(project, scope, created_at_epoch DESC, importance_score DESC)`,
// Index for scope='global' queries (common pattern in search)
`CREATE INDEX IF NOT EXISTS idx_observations_global_scope
ON observations(scope, importance_score DESC, created_at_epoch DESC)
WHERE scope = 'global'`,
// Index for vector search result deduplication by observation
`CREATE INDEX IF NOT EXISTS idx_vectors_observation_lookup
ON vectors(doc_type, sqlite_id, project)
WHERE doc_type = 'observation'`,
// Index for FTS search result ordering
`CREATE INDEX IF NOT EXISTS idx_observations_fts_ordering
ON observations(project, importance_score DESC)
WHERE (is_archived = 0 OR is_archived IS NULL) AND (is_superseded = 0 OR is_superseded IS NULL)`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: index may already exist
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
sqls := []string{
"DROP INDEX IF EXISTS idx_observations_project_scope_created",
"DROP INDEX IF EXISTS idx_observations_global_scope",
"DROP INDEX IF EXISTS idx_vectors_observation_lookup",
"DROP INDEX IF EXISTS idx_observations_fts_ordering",
}
for _, s := range sqls {
_ = tx.Exec(s).Error
}
return nil
},
},
// Migration 016: Add covering indexes for relation joins and active observations
{
ID: "016_relation_and_active_indexes",
Migrate: func(tx *gorm.DB) error {
sqls := []string{
// Covering index for observation relation joins (common JOIN patterns)
// Speeds up queries like: JOIN observation_relations ON source_id = obs.id WHERE relation_type = ?
`CREATE INDEX IF NOT EXISTS idx_relations_source_type_target
ON observation_relations(source_observation_id, relation_type, target_observation_id)`,
// Covering index for reverse relation lookups
`CREATE INDEX IF NOT EXISTS idx_relations_target_type_source
ON observation_relations(target_observation_id, relation_type, source_observation_id)`,
// Partial index for active (non-archived, non-superseded) observations
// Optimizes activeObservationFilter queries
`CREATE INDEX IF NOT EXISTS idx_observations_active
ON observations(project, importance_score DESC, created_at_epoch DESC)
WHERE (is_archived = 0 OR is_archived IS NULL) AND (is_superseded = 0 OR is_superseded IS NULL)`,
}
for _, s := range sqls {
if err := tx.Exec(s).Error; err != nil {
// Non-fatal: index may already exist
continue
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
sqls := []string{
"DROP INDEX IF EXISTS idx_relations_source_type_target",
"DROP INDEX IF EXISTS idx_relations_target_type_source",
"DROP INDEX IF EXISTS idx_observations_active",
}
for _, s := range sqls {
_ = tx.Exec(s).Error
}
return nil
},
},
}) })
if err := m.Migrate(); err != nil { if err := m.Migrate(); err != nil {
+8 -3
View File
@@ -48,7 +48,7 @@ func (s *SDKSession) BeforeCreate(tx *gorm.DB) error {
type Observation struct { type Observation struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
SDKSessionID string `gorm:"index;not null"` SDKSessionID string `gorm:"index;not null"`
Project string `gorm:"index;not null"` Project string `gorm:"index:idx_observations_project;index:idx_observations_project_created,priority:1;not null"`
Scope models.ObservationScope `gorm:"type:text;default:'project';check:scope IN ('project', 'global');index:idx_observations_scope;index:idx_observations_project_scope,priority:2"` Scope models.ObservationScope `gorm:"type:text;default:'project';check:scope IN ('project', 'global');index:idx_observations_scope;index:idx_observations_project_scope,priority:2"`
Type models.ObservationType `gorm:"type:text;check:type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change');index;not null"` Type models.ObservationType `gorm:"type:text;check:type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change');index;not null"`
@@ -66,7 +66,7 @@ type Observation struct {
PromptNumber sql.NullInt64 PromptNumber sql.NullInt64
DiscoveryTokens int64 `gorm:"default:0"` DiscoveryTokens int64 `gorm:"default:0"`
CreatedAt string `gorm:"not null"` CreatedAt string `gorm:"not null"`
CreatedAtEpoch int64 `gorm:"index:idx_observations_created,sort:desc;not null"` CreatedAtEpoch int64 `gorm:"index:idx_observations_created,sort:desc;index:idx_observations_project_created,priority:2,sort:desc;not null"`
// Importance scoring fields // Importance scoring fields
ImportanceScore float64 `gorm:"type:real;default:1.0;index:idx_observations_importance,priority:1,sort:desc"` ImportanceScore float64 `gorm:"type:real;default:1.0;index:idx_observations_importance,priority:1,sort:desc"`
@@ -74,7 +74,12 @@ type Observation struct {
RetrievalCount int `gorm:"default:0"` RetrievalCount int `gorm:"default:0"`
LastRetrievedAt sql.NullInt64 `gorm:"column:last_retrieved_at_epoch"` LastRetrievedAt sql.NullInt64 `gorm:"column:last_retrieved_at_epoch"`
ScoreUpdatedAt sql.NullInt64 `gorm:"column:score_updated_at_epoch;index:idx_observations_score_updated"` ScoreUpdatedAt sql.NullInt64 `gorm:"column:score_updated_at_epoch;index:idx_observations_score_updated"`
IsSuperseded int `gorm:"default:0;index:idx_observations_superseded,priority:1"` IsSuperseded int `gorm:"default:0;index:idx_observations_superseded;index:idx_observations_active,priority:2"`
// Archival fields
IsArchived int `gorm:"default:0;index:idx_observations_archived;index:idx_observations_active,priority:1"`
ArchivedAt sql.NullInt64 `gorm:"column:archived_at_epoch"`
ArchivedReason sql.NullString
} }
func (Observation) TableName() string { return "observations" } func (Observation) TableName() string { return "observations" }
+578 -36
View File
@@ -5,17 +5,36 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models" "github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
) )
// MaxObservationsPerProject is the maximum number of observations to keep per project. // MaxObservationsPerProject is the maximum number of observations to keep per project.
const MaxObservationsPerProject = 100 const MaxObservationsPerProject = 100
// cleanupQueueSize is the buffer size for the cleanup queue.
const cleanupQueueSize = 100
// commonWords is a package-level set for O(1) lookup of stop words.
// Created once at init time to avoid repeated map allocations.
var commonWords = map[string]struct{}{
"the": {}, "and": {}, "or": {}, "but": {}, "in": {},
"on": {}, "at": {}, "to": {}, "for": {}, "of": {},
"with": {}, "by": {}, "from": {}, "as": {}, "is": {},
"was": {}, "are": {}, "were": {}, "be": {}, "been": {},
"being": {}, "have": {}, "has": {}, "had": {}, "do": {},
"does": {}, "did": {}, "will": {}, "would": {}, "should": {},
"could": {}, "may": {}, "might": {}, "must": {}, "can": {},
}
// CleanupFunc is a callback for when observations are cleaned up. // CleanupFunc is a callback for when observations are cleaned up.
// Receives the IDs of deleted observations for downstream cleanup (e.g., vector DB). // Receives the IDs of deleted observations for downstream cleanup (e.g., vector DB).
type CleanupFunc func(ctx context.Context, deletedIDs []int64) type CleanupFunc func(ctx context.Context, deletedIDs []int64)
@@ -25,19 +44,89 @@ type ObservationStore struct {
db *gorm.DB db *gorm.DB
rawDB *sql.DB rawDB *sql.DB
cleanupFunc CleanupFunc cleanupFunc CleanupFunc
conflictStore interface{} // Placeholder for ConflictStore (Phase 4) conflictStore any // Placeholder for ConflictStore (Phase 4)
relationStore interface{} // Placeholder for RelationStore (Phase 4) relationStore any // Placeholder for RelationStore (Phase 4)
// Cleanup queue for async observation cleanup with proper error handling
cleanupQueue chan string
cleanupWg sync.WaitGroup
cleanupOnce sync.Once
cleanupStarted atomic.Bool // tracks if cleanup worker was started
stopCleanup chan struct{}
} }
// NewObservationStore creates a new observation store. // NewObservationStore creates a new observation store.
// The conflictStore and relationStore parameters are optional (can be nil) and will be used in Phase 4. // The conflictStore and relationStore parameters are optional (can be nil) and will be used in Phase 4.
func NewObservationStore(store *Store, cleanupFunc CleanupFunc, conflictStore, relationStore interface{}) *ObservationStore { func NewObservationStore(store *Store, cleanupFunc CleanupFunc, conflictStore, relationStore any) *ObservationStore {
return &ObservationStore{ s := &ObservationStore{
db: store.DB, db: store.DB,
rawDB: store.GetRawDB(), rawDB: store.GetRawDB(),
cleanupFunc: cleanupFunc, cleanupFunc: cleanupFunc,
conflictStore: conflictStore, conflictStore: conflictStore,
relationStore: relationStore, relationStore: relationStore,
cleanupQueue: make(chan string, cleanupQueueSize),
stopCleanup: make(chan struct{}),
}
// Start the cleanup worker
s.startCleanupWorker()
return s
}
// startCleanupWorker starts the background cleanup worker.
func (s *ObservationStore) startCleanupWorker() {
s.cleanupOnce.Do(func() {
s.cleanupStarted.Store(true)
s.cleanupWg.Add(1)
go s.cleanupWorker()
})
}
// cleanupWorker processes cleanup requests from the queue.
func (s *ObservationStore) cleanupWorker() {
defer s.cleanupWg.Done()
for {
select {
case <-s.stopCleanup:
// Drain remaining items in queue before exiting
for {
select {
case project := <-s.cleanupQueue:
s.processCleanup(project)
default:
return
}
}
case project := <-s.cleanupQueue:
s.processCleanup(project)
}
}
}
// processCleanup performs the actual cleanup for a project.
func (s *ObservationStore) processCleanup(project string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deletedIDs, err := s.CleanupOldObservations(ctx, project)
if err != nil {
log.Warn().Err(err).Str("project", project).Msg("Failed to cleanup old observations")
return
}
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
s.cleanupFunc(ctx, deletedIDs)
log.Debug().Str("project", project).Int("count", len(deletedIDs)).Msg("Cleaned up old observations")
}
}
// Close stops the cleanup worker and waits for it to finish.
// Safe to call even if the worker was never started.
func (s *ObservationStore) Close() {
// Only stop if worker was actually started to avoid deadlock
if s.cleanupStarted.Load() {
close(s.stopCleanup)
s.cleanupWg.Wait()
} }
} }
@@ -86,16 +175,15 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p
return 0, 0, err return 0, 0, err
} }
// Cleanup old observations beyond the limit for this project (async to not block handler) // Queue cleanup of old observations beyond the limit for this project (async to not block handler)
if project != "" { if project != "" {
go func(proj string) { select {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) case s.cleanupQueue <- project:
defer cancel() // Successfully queued for cleanup
deletedIDs, _ := s.CleanupOldObservations(cleanupCtx, proj) default:
if len(deletedIDs) > 0 && s.cleanupFunc != nil { // Queue is full, log a warning instead of silently dropping
s.cleanupFunc(cleanupCtx, deletedIDs) log.Warn().Str("project", project).Msg("Cleanup queue full, skipping cleanup for this observation")
} }
}(project)
} }
// Note: Conflict and relation detection intentionally omitted for now // Note: Conflict and relation detection intentionally omitted for now
@@ -104,6 +192,89 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p
return dbObs.ID, nowEpoch, nil return dbObs.ID, nowEpoch, nil
} }
// ObservationUpdate contains fields that can be updated on an observation.
// Only non-nil fields will be updated.
type ObservationUpdate struct {
Title *string // New title
Subtitle *string // New subtitle
Narrative *string // New narrative
Facts *[]string // New facts (replaces existing)
Concepts *[]string // New concepts (replaces existing)
FilesRead *[]string // New files read (replaces existing)
FilesModified *[]string // New files modified (replaces existing)
Scope *string // New scope (project or global)
}
// UpdateObservation updates an existing observation with the provided fields.
// Only non-nil fields in the update struct will be modified.
// Returns the updated observation or an error.
func (s *ObservationStore) UpdateObservation(ctx context.Context, id int64, update *ObservationUpdate) (*models.Observation, error) {
if update == nil {
return nil, fmt.Errorf("update cannot be nil")
}
// First, verify the observation exists
var dbObs Observation
if err := s.db.WithContext(ctx).First(&dbObs, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("observation not found: %d", id)
}
return nil, err
}
// Build update map with only provided fields
updates := make(map[string]any)
if update.Title != nil {
updates["title"] = sql.NullString{String: *update.Title, Valid: true}
}
if update.Subtitle != nil {
updates["subtitle"] = sql.NullString{String: *update.Subtitle, Valid: true}
}
if update.Narrative != nil {
updates["narrative"] = sql.NullString{String: *update.Narrative, Valid: true}
}
if update.Facts != nil {
factsJSON, _ := json.Marshal(*update.Facts)
updates["facts"] = string(factsJSON)
}
if update.Concepts != nil {
conceptsJSON, _ := json.Marshal(*update.Concepts)
updates["concepts"] = string(conceptsJSON)
}
if update.FilesRead != nil {
filesReadJSON, _ := json.Marshal(*update.FilesRead)
updates["files_read"] = string(filesReadJSON)
}
if update.FilesModified != nil {
filesModifiedJSON, _ := json.Marshal(*update.FilesModified)
updates["files_modified"] = string(filesModifiedJSON)
}
if update.Scope != nil {
updates["scope"] = sql.NullString{String: *update.Scope, Valid: true}
}
// Add updated_at timestamp
updates["updated_at_epoch"] = sql.NullInt64{Int64: time.Now().Unix(), Valid: true}
if len(updates) == 0 {
// Nothing to update, just return existing observation
return toModelObservation(&dbObs), nil
}
// Perform the update
if err := s.db.WithContext(ctx).Model(&Observation{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, fmt.Errorf("failed to update observation: %w", err)
}
// Fetch the updated observation
if err := s.db.WithContext(ctx).First(&dbObs, id).Error; err != nil {
return nil, err
}
return toModelObservation(&dbObs), nil
}
// GetObservationByID retrieves an observation by its ID. // GetObservationByID retrieves an observation by its ID.
func (s *ObservationStore) GetObservationByID(ctx context.Context, id int64) (*models.Observation, error) { func (s *ObservationStore) GetObservationByID(ctx context.Context, id int64) (*models.Observation, error) {
var dbObs Observation var dbObs Observation
@@ -134,6 +305,8 @@ func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64
query = query.Order("created_at_epoch DESC") query = query.Order("created_at_epoch DESC")
case "importance": case "importance":
query = query.Order("importance_score DESC, created_at_epoch DESC") query = query.Order("importance_score DESC, created_at_epoch DESC")
case "score_desc":
query = query.Order("importance_score DESC, created_at_epoch DESC")
default: default:
// Default: importance first, then recency // Default: importance first, then recency
query = query.Order("COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC") query = query.Order("COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC")
@@ -152,6 +325,60 @@ func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64
return toModelObservations(dbObservations), nil return toModelObservations(dbObservations), nil
} }
// GetObservationsByIDsPreserveOrder retrieves observations by IDs, preserving the input order.
// This is useful when the caller has already sorted/ranked the IDs (e.g., by vector similarity).
func (s *ObservationStore) GetObservationsByIDsPreserveOrder(ctx context.Context, ids []int64) ([]*models.Observation, error) {
if len(ids) == 0 {
return nil, nil
}
// Fetch all observations in a single query
var dbObservations []Observation
err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&dbObservations).Error
if err != nil {
return nil, err
}
// Build ID -> observation map for O(1) lookups
obsMap := make(map[int64]*Observation, len(dbObservations))
for i := range dbObservations {
obsMap[int64(dbObservations[i].ID)] = &dbObservations[i]
}
// Reconstruct in original order
result := make([]*models.Observation, 0, len(ids))
for _, id := range ids {
if obs, ok := obsMap[id]; ok {
result = append(result, toModelObservation(obs))
}
}
return result, nil
}
// BatchGetObservationsWithScores retrieves observations with associated scores.
// Returns a map of ID -> observation for efficient lookup.
func (s *ObservationStore) BatchGetObservationsWithScores(ctx context.Context, ids []int64) (map[int64]*models.Observation, error) {
if len(ids) == 0 {
return make(map[int64]*models.Observation), nil
}
// Fetch all observations in a single query
var dbObservations []Observation
err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&dbObservations).Error
if err != nil {
return nil, err
}
// Build result map
result := make(map[int64]*models.Observation, len(dbObservations))
for i := range dbObservations {
result[int64(dbObservations[i].ID)] = toModelObservation(&dbObservations[i])
}
return result, nil
}
// GetRecentObservations retrieves recent observations for a project. // GetRecentObservations retrieves recent observations for a project.
// This includes project-scoped observations for the specified project AND global observations. // This includes project-scoped observations for the specified project AND global observations.
// Results are ordered by importance_score DESC, then created_at_epoch DESC. // Results are ordered by importance_score DESC, then created_at_epoch DESC.
@@ -169,13 +396,13 @@ func (s *ObservationStore) GetRecentObservations(ctx context.Context, project st
return toModelObservations(dbObservations), nil return toModelObservations(dbObservations), nil
} }
// GetActiveObservations retrieves recent non-superseded observations for a project. // GetActiveObservations retrieves recent non-superseded, non-archived observations for a project.
// This excludes observations that have been marked as superseded by newer ones. // This excludes observations that have been marked as superseded or archived.
// Results are ordered by importance_score DESC, then created_at_epoch DESC. // Results are ordered by importance_score DESC, then created_at_epoch DESC.
func (s *ObservationStore) GetActiveObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) { func (s *ObservationStore) GetActiveObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
var dbObservations []Observation var dbObservations []Observation
err := s.db.WithContext(ctx). err := s.db.WithContext(ctx).
Scopes(projectScopeFilter(project), notSupersededFilter(), importanceOrdering()). Scopes(projectScopeFilter(project), activeObservationFilter(), importanceOrdering()).
Limit(limit). Limit(limit).
Find(&dbObservations).Error Find(&dbObservations).Error
@@ -245,7 +472,57 @@ func (s *ObservationStore) GetAllRecentObservations(ctx context.Context, limit i
return toModelObservations(dbObservations), nil return toModelObservations(dbObservations), nil
} }
// GetAllRecentObservationsPaginated retrieves recent observations with pagination.
func (s *ObservationStore) GetAllRecentObservationsPaginated(ctx context.Context, limit, offset int) ([]*models.Observation, int64, error) {
var dbObservations []Observation
var total int64
// Get total count
if err := s.db.WithContext(ctx).Model(&Observation{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
err := s.db.WithContext(ctx).
Scopes(importanceOrdering()).
Limit(limit).
Offset(offset).
Find(&dbObservations).Error
if err != nil {
return nil, 0, err
}
return toModelObservations(dbObservations), total, nil
}
// GetObservationsByProjectStrictPaginated retrieves observations strictly from a project with pagination.
func (s *ObservationStore) GetObservationsByProjectStrictPaginated(ctx context.Context, project string, limit, offset int) ([]*models.Observation, int64, error) {
var dbObservations []Observation
var total int64
// Get total count for project
if err := s.db.WithContext(ctx).Model(&Observation{}).Where("project = ?", project).Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
err := s.db.WithContext(ctx).
Where("project = ?", project).
Scopes(importanceOrdering()).
Limit(limit).
Offset(offset).
Find(&dbObservations).Error
if err != nil {
return nil, 0, err
}
return toModelObservations(dbObservations), total, nil
}
// GetAllObservations retrieves all observations (for vector rebuild). // GetAllObservations retrieves all observations (for vector rebuild).
// Note: For large datasets, prefer GetAllObservationsIterator to avoid memory issues.
func (s *ObservationStore) GetAllObservations(ctx context.Context) ([]*models.Observation, error) { func (s *ObservationStore) GetAllObservations(ctx context.Context) ([]*models.Observation, error) {
var dbObservations []Observation var dbObservations []Observation
err := s.db.WithContext(ctx). err := s.db.WithContext(ctx).
@@ -259,6 +536,51 @@ func (s *ObservationStore) GetAllObservations(ctx context.Context) ([]*models.Ob
return toModelObservations(dbObservations), nil return toModelObservations(dbObservations), nil
} }
// GetAllObservationsIterator returns observations in batches to avoid loading all into memory.
// The callback is called for each batch. Return false from callback to stop iteration.
// batchSize controls how many observations are loaded at once (default 500 if <= 0).
func (s *ObservationStore) GetAllObservationsIterator(ctx context.Context, batchSize int, callback func([]*models.Observation) bool) error {
if batchSize <= 0 {
batchSize = 500
}
var lastID int64 = 0
for {
// Check context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
var dbObservations []Observation
err := s.db.WithContext(ctx).
Where("id > ?", lastID).
Order("id ASC").
Limit(batchSize).
Find(&dbObservations).Error
if err != nil {
return err
}
if len(dbObservations) == 0 {
break // No more observations
}
// Update cursor for next batch
lastID = dbObservations[len(dbObservations)-1].ID
// Convert and call callback
observations := toModelObservations(dbObservations)
if !callback(observations) {
break // Callback requested stop
}
}
return nil
}
// SearchObservationsFTS performs full-text search on observations using FTS5. // SearchObservationsFTS performs full-text search on observations using FTS5.
// Falls back to LIKE search if FTS5 fails. // Falls back to LIKE search if FTS5 fails.
func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, project string, limit int) ([]*models.Observation, error) { func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, project string, limit int) ([]*models.Observation, error) {
@@ -314,14 +636,26 @@ func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, pro
} }
// searchObservationsLike performs fallback LIKE search on observations using GORM. // searchObservationsLike performs fallback LIKE search on observations using GORM.
// Limits to 2 keywords to prevent expensive OR queries that SQLite optimizes poorly.
// This is a fallback path when FTS returns no results, so we prioritize performance.
func (s *ObservationStore) searchObservationsLike(ctx context.Context, keywords []string, project string, limit int) ([]*models.Observation, error) { func (s *ObservationStore) searchObservationsLike(ctx context.Context, keywords []string, project string, limit int) ([]*models.Observation, error) {
if len(keywords) == 0 { if len(keywords) == 0 {
return nil, nil return nil, nil
} }
// Limit keywords to prevent excessive OR conditions that hurt query planning.
// SQLite performs significantly better with fewer LIKE conditions.
// Using 2 instead of 5 reduces query complexity from O(15) to O(6) conditions
// (each keyword creates 3 LIKE conditions for title, subtitle, narrative).
const maxKeywords = 2
if len(keywords) > maxKeywords {
keywords = keywords[:maxKeywords]
}
// Build LIKE conditions for each keyword // Build LIKE conditions for each keyword
var conditions []string // Pre-allocate for efficiency: maxKeywords conditions × 3 args each + 1 project arg
var args []interface{} conditions := make([]string, 0, len(keywords))
args := make([]any, 0, len(keywords)*3+1)
for _, kw := range keywords { for _, kw := range keywords {
pattern := "%" + kw + "%" pattern := "%" + kw + "%"
@@ -358,6 +692,217 @@ func (s *ObservationStore) DeleteObservations(ctx context.Context, ids []int64)
return result.RowsAffected, result.Error return result.RowsAffected, result.Error
} }
// DeleteObservation deletes a single observation by ID.
func (s *ObservationStore) DeleteObservation(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).Delete(&Observation{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("observation %d not found", id)
}
return nil
}
// MarkAsSuperseded marks an observation as superseded (stale).
func (s *ObservationStore) MarkAsSuperseded(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).
Model(&Observation{}).
Where("id = ?", id).
Update("is_superseded", 1)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("observation %d not found", id)
}
return nil
}
// MarkAsSupersededBatch marks multiple observations as superseded in a single query.
// Returns the number of observations updated and any error.
func (s *ObservationStore) MarkAsSupersededBatch(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
result := s.db.WithContext(ctx).
Model(&Observation{}).
Where("id IN ?", ids).
Update("is_superseded", 1)
return result.RowsAffected, result.Error
}
// ArchiveObservation archives a single observation with an optional reason.
func (s *ObservationStore) ArchiveObservation(ctx context.Context, id int64, reason string) error {
updates := map[string]any{
"is_archived": 1,
"archived_at_epoch": time.Now().UnixMilli(),
}
if reason != "" {
updates["archived_reason"] = reason
}
result := s.db.WithContext(ctx).
Model(&Observation{}).
Where("id = ?", id).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("observation %d not found", id)
}
return nil
}
// UnarchiveObservation restores an archived observation.
func (s *ObservationStore) UnarchiveObservation(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).
Model(&Observation{}).
Where("id = ?", id).
Updates(map[string]any{
"is_archived": 0,
"archived_at_epoch": nil,
"archived_reason": nil,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("observation %d not found", id)
}
return nil
}
// ArchiveOldObservations archives observations older than the specified age.
// Returns the count of archived observations and their IDs.
func (s *ObservationStore) ArchiveOldObservations(ctx context.Context, project string, maxAgeDays int, reason string) ([]int64, error) {
if maxAgeDays <= 0 {
maxAgeDays = 90 // Default: archive observations older than 90 days
}
cutoffEpoch := time.Now().AddDate(0, 0, -maxAgeDays).UnixMilli()
if reason == "" {
reason = fmt.Sprintf("auto-archived: older than %d days", maxAgeDays)
}
var idsToArchive []int64
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Find observations to archive (not already archived, older than cutoff)
query := tx.Model(&Observation{}).
Where("created_at_epoch < ?", cutoffEpoch).
Where("COALESCE(is_archived, 0) = 0")
if project != "" {
query = query.Where("project = ?", project)
}
if err := query.Pluck("id", &idsToArchive).Error; err != nil {
return err
}
if len(idsToArchive) == 0 {
return nil
}
// Archive the observations
now := time.Now().UnixMilli()
return tx.Model(&Observation{}).
Where("id IN ?", idsToArchive).
Updates(map[string]any{
"is_archived": 1,
"archived_at_epoch": now,
"archived_reason": reason,
}).Error
})
if err != nil {
return nil, err
}
return idsToArchive, nil
}
// GetArchivedObservations retrieves archived observations for a project.
func (s *ObservationStore) GetArchivedObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
var dbObservations []Observation
query := s.db.WithContext(ctx).
Where("COALESCE(is_archived, 0) = 1")
if project != "" {
query = query.Where("project = ?", project)
}
err := query.
Order("archived_at_epoch DESC").
Limit(limit).
Find(&dbObservations).Error
if err != nil {
return nil, err
}
return toModelObservations(dbObservations), nil
}
// GetArchivalStats returns statistics about archived observations.
// Optimized to use a single query instead of 4 separate queries.
func (s *ObservationStore) GetArchivalStats(ctx context.Context, project string) (*ArchivalStats, error) {
// Use a single query with conditional aggregation to get all stats at once.
// This is much faster than 4 separate queries (saves 3 round trips).
type statsResult struct {
TotalCount int64
ArchivedCount int64
OldestEpoch *int64 // Pointer to handle NULL
NewestEpoch *int64
}
var result statsResult
query := s.db.WithContext(ctx).Model(&Observation{}).
Select(`
COUNT(*) as total_count,
SUM(CASE WHEN COALESCE(is_archived, 0) = 1 THEN 1 ELSE 0 END) as archived_count,
MIN(CASE WHEN COALESCE(is_archived, 0) = 1 THEN archived_at_epoch END) as oldest_epoch,
MAX(CASE WHEN COALESCE(is_archived, 0) = 1 THEN archived_at_epoch END) as newest_epoch
`)
if project != "" {
query = query.Where("project = ?", project)
}
if err := query.Scan(&result).Error; err != nil {
return nil, err
}
stats := &ArchivalStats{
TotalCount: result.TotalCount,
ArchivedCount: result.ArchivedCount,
ActiveCount: result.TotalCount - result.ArchivedCount,
}
if result.OldestEpoch != nil {
stats.OldestArchivedEpoch = *result.OldestEpoch
}
if result.NewestEpoch != nil {
stats.NewestArchivedEpoch = *result.NewestEpoch
}
return stats, nil
}
// ArchivalStats contains statistics about archived observations.
type ArchivalStats struct {
TotalCount int64 `json:"total_count"`
ActiveCount int64 `json:"active_count"`
ArchivedCount int64 `json:"archived_count"`
OldestArchivedEpoch int64 `json:"oldest_archived_epoch,omitempty"`
NewestArchivedEpoch int64 `json:"newest_archived_epoch,omitempty"`
}
// CleanupOldObservations removes observations beyond the limit for a project. // CleanupOldObservations removes observations beyond the limit for a project.
// Returns the IDs of deleted observations. // Returns the IDs of deleted observations.
func (s *ObservationStore) CleanupOldObservations(ctx context.Context, project string) ([]int64, error) { func (s *ObservationStore) CleanupOldObservations(ctx context.Context, project string) ([]int64, error) {
@@ -418,10 +963,12 @@ func projectScopeFilter(project string) func(*gorm.DB) *gorm.DB {
} }
} }
// notSupersededFilter filters out superseded observations. // activeObservationFilter filters for active (non-archived, non-superseded) observations.
func notSupersededFilter() func(*gorm.DB) *gorm.DB { // This is more efficient than chaining notSupersededFilter + notArchivedFilter
// as it produces a single WHERE clause for the query optimizer.
func activeObservationFilter() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB {
return db.Where("COALESCE(is_superseded, 0) = 0") return db.Where("COALESCE(is_archived, 0) = 0 AND COALESCE(is_superseded, 0) = 0")
} }
} }
@@ -437,23 +984,17 @@ func importanceOrdering() func(*gorm.DB) *gorm.DB {
// ==================== // ====================
// extractKeywords extracts keywords from a search query. // extractKeywords extracts keywords from a search query.
// Uses package-level commonWords map for O(1) stop word filtering.
func extractKeywords(query string) []string { func extractKeywords(query string) []string {
words := strings.Fields(strings.ToLower(query)) words := strings.Fields(strings.ToLower(query))
var keywords []string keywords := make([]string, 0, len(words)) // Pre-allocate for typical case
commonWords := map[string]bool{
"the": true, "and": true, "or": true, "but": true, "in": true,
"on": true, "at": true, "to": true, "for": true, "of": true,
"with": true, "by": true, "from": true, "as": true, "is": true,
"was": true, "are": true, "were": true, "be": true, "been": true,
"being": true, "have": true, "has": true, "had": true, "do": true,
"does": true, "did": true, "will": true, "would": true, "should": true,
"could": true, "may": true, "might": true, "must": true, "can": true,
}
for _, word := range words { for _, word := range words {
// Skip short words and common words // Skip short words and common stop words
if len(word) <= 3 || commonWords[word] { if len(word) <= 3 {
continue
}
if _, isCommon := commonWords[word]; isCommon {
continue continue
} }
keywords = append(keywords, word) keywords = append(keywords, word)
@@ -464,7 +1005,8 @@ func extractKeywords(query string) []string {
// scanObservationRows scans multiple observations from raw SQL rows. // scanObservationRows scans multiple observations from raw SQL rows.
func scanObservationRows(rows *sql.Rows) ([]*models.Observation, error) { func scanObservationRows(rows *sql.Rows) ([]*models.Observation, error) {
var observations []*models.Observation // Pre-allocate with reasonable initial capacity to avoid repeated slice growth
observations := make([]*models.Observation, 0, 64)
for rows.Next() { for rows.Next() {
obs, err := scanObservation(rows) obs, err := scanObservation(rows)
if err != nil { if err != nil {
@@ -476,7 +1018,7 @@ func scanObservationRows(rows *sql.Rows) ([]*models.Observation, error) {
} }
// scanObservation scans a single observation from a row scanner. // scanObservation scans a single observation from a row scanner.
func scanObservation(scanner interface{ Scan(...interface{}) error }) (*models.Observation, error) { func scanObservation(scanner interface{ Scan(...any) error }) (*models.Observation, error) {
var obs models.Observation var obs models.Observation
var factsJSON, conceptsJSON, filesReadJSON, filesModifiedJSON, fileMtimesJSON []byte var factsJSON, conceptsJSON, filesReadJSON, filesModifiedJSON, fileMtimesJSON []byte
var isSuperseded int var isSuperseded int
+44 -1
View File
@@ -3,6 +3,8 @@ package gorm
import ( import (
"context" "context"
"fmt"
"strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -59,12 +61,23 @@ func (s *ObservationStore) UpdateImportanceScore(ctx context.Context, id int64,
} }
// UpdateImportanceScores bulk updates importance scores for multiple observations. // UpdateImportanceScores bulk updates importance scores for multiple observations.
// This is more efficient than individual updates for batch recalculation. // Uses a single SQL statement with CASE/WHEN for efficient batch updates.
func (s *ObservationStore) UpdateImportanceScores(ctx context.Context, scores map[int64]float64) error { func (s *ObservationStore) UpdateImportanceScores(ctx context.Context, scores map[int64]float64) error {
if len(scores) == 0 { if len(scores) == 0 {
return nil return nil
} }
// For small batches, use simple individual updates
if len(scores) <= 5 {
return s.updateScoresIndividually(ctx, scores)
}
// For larger batches, use CASE/WHEN SQL for single-query update
return s.updateScoresBatch(ctx, scores)
}
// updateScoresIndividually updates scores one at a time (efficient for small batches).
func (s *ObservationStore) updateScoresIndividually(ctx context.Context, scores map[int64]float64) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
@@ -85,6 +98,36 @@ func (s *ObservationStore) UpdateImportanceScores(ctx context.Context, scores ma
}) })
} }
// updateScoresBatch updates multiple scores in a single SQL statement using CASE/WHEN.
// This is much more efficient for large batches (O(1) queries instead of O(n)).
func (s *ObservationStore) updateScoresBatch(ctx context.Context, scores map[int64]float64) error {
now := time.Now().UnixMilli()
// Build CASE/WHEN clause for importance_score
// UPDATE observations SET
// importance_score = CASE id WHEN 1 THEN 0.5 WHEN 2 THEN 0.8 ... END,
// score_updated_at_epoch = ?
// WHERE id IN (1, 2, ...)
ids := make([]int64, 0, len(scores))
caseBuilder := strings.Builder{}
caseBuilder.WriteString("CASE id ")
for id, score := range scores {
ids = append(ids, id)
caseBuilder.WriteString(fmt.Sprintf("WHEN %d THEN %f ", id, score))
}
caseBuilder.WriteString("END")
// Use raw SQL for the batch update
sql := fmt.Sprintf(
"UPDATE observations SET importance_score = %s, score_updated_at_epoch = ? WHERE id IN ?",
caseBuilder.String(),
)
return s.db.WithContext(ctx).Exec(sql, now, ids).Error
}
// GetObservationsNeedingScoreUpdate returns observations that need their importance score recalculated. // GetObservationsNeedingScoreUpdate returns observations that need their importance score recalculated.
// Returns observations where score_updated_at_epoch is NULL or older than the threshold. // Returns observations where score_updated_at_epoch is NULL or older than the threshold.
func (s *ObservationStore) GetObservationsNeedingScoreUpdate(ctx context.Context, threshold time.Duration, limit int) ([]*models.Observation, error) { func (s *ObservationStore) GetObservationsNeedingScoreUpdate(ctx context.Context, threshold time.Duration, limit int) ([]*models.Observation, error) {
+33 -15
View File
@@ -116,26 +116,44 @@ func (s *SessionStore) FindAnySDKSession(ctx context.Context, claudeSessionID st
} }
// IncrementPromptCounter increments the prompt counter and returns the new value. // IncrementPromptCounter increments the prompt counter and returns the new value.
// Uses a single SQL query with RETURNING clause for optimal performance.
func (s *SessionStore) IncrementPromptCounter(ctx context.Context, id int64) (int, error) { func (s *SessionStore) IncrementPromptCounter(ctx context.Context, id int64) (int, error) {
// Atomic increment using GORM expression // Use raw SQL with RETURNING to get updated value in single query
err := s.db.WithContext(ctx). // SQLite supports RETURNING since version 3.35.0 (2021-03-12)
Model(&SDKSession{}). var newCounter int
Where("id = ?", id). err := s.db.WithContext(ctx).Raw(`
Update("prompt_counter", gorm.Expr("COALESCE(prompt_counter, 0) + 1")).Error UPDATE sdk_sessions
SET prompt_counter = COALESCE(prompt_counter, 0) + 1
WHERE id = ?
RETURNING prompt_counter
`, id).Scan(&newCounter).Error
if err != nil { if err != nil {
// Fallback for older SQLite versions without RETURNING support
if err.Error() == "near \"RETURNING\": syntax error" || newCounter == 0 {
// Atomic increment
updateErr := s.db.WithContext(ctx).
Model(&SDKSession{}).
Where("id = ?", id).
Update("prompt_counter", gorm.Expr("COALESCE(prompt_counter, 0) + 1")).Error
if updateErr != nil {
return 0, updateErr
}
// Fetch updated value
var sess SDKSession
fetchErr := s.db.WithContext(ctx).
Select("prompt_counter").
First(&sess, id).Error
if fetchErr != nil {
return 0, fetchErr
}
return sess.PromptCounter, nil
}
return 0, err return 0, err
} }
// Fetch updated value return newCounter, nil
var sess SDKSession
err = s.db.WithContext(ctx).
Select("prompt_counter").
First(&sess, id).Error
if err != nil {
return 0, err
}
return sess.PromptCounter, nil
} }
// GetPromptCounter returns the current prompt counter for a session. // GetPromptCounter returns the current prompt counter for a session.
+428 -9
View File
@@ -2,11 +2,16 @@
package gorm package gorm
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"slices"
"sync"
"time"
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo" sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
_ "github.com/mattn/go-sqlite3" // Import SQLite driver with FTS5 support _ "github.com/mattn/go-sqlite3" // Import SQLite driver with FTS5 support
"github.com/rs/zerolog/log"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -14,8 +19,15 @@ import (
// Store represents the GORM database connection with sqlite-vec support. // Store represents the GORM database connection with sqlite-vec support.
type Store struct { type Store struct {
DB *gorm.DB DB *gorm.DB
sqlDB *sql.DB // For FTS5 and sqlite-vec operations that require raw SQL sqlDB *sql.DB // For FTS5 and sqlite-vec operations that require raw SQL
metrics *PoolMetrics
// Health check caching to reduce database load from frequent monitoring
healthCacheMu sync.RWMutex
cachedHealth *HealthInfo
healthCacheTime time.Time
healthCacheTTL time.Duration // Default: 5 seconds
} }
// Config holds database configuration. // Config holds database configuration.
@@ -71,8 +83,10 @@ func NewStore(cfg Config) (*Store, error) {
} }
store := &Store{ store := &Store{
DB: db, DB: db,
sqlDB: sqlDB, sqlDB: sqlDB,
metrics: NewPoolMetrics(100), // Track last 100 latency samples
healthCacheTTL: 5 * time.Second, // Cache health checks for 5 seconds
} }
// 7. Run migrations FIRST (before PRAGMA commands) // 7. Run migrations FIRST (before PRAGMA commands)
@@ -80,18 +94,56 @@ func NewStore(cfg Config) (*Store, error) {
return nil, fmt.Errorf("run migrations: %w", err) return nil, fmt.Errorf("run migrations: %w", err)
} }
// 8. CRITICAL: Set WAL mode and synchronous mode via raw SQL // 8. CRITICAL: Set WAL mode and other performance pragmas
// Use raw sqlDB to avoid GORM transaction issues // Use raw sqlDB to avoid GORM transaction issues
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil { pragmas := []string{
return nil, fmt.Errorf("set WAL mode: %w", err) "PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA cache_size=-64000", // 64MB cache (negative = KB)
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
} }
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil { for _, pragma := range pragmas {
return nil, fmt.Errorf("set synchronous mode: %w", err) if _, err := sqlDB.Exec(pragma); err != nil {
log.Warn().Str("pragma", pragma).Err(err).Msg("Failed to set pragma (non-fatal)")
}
} }
// 9. Warm the connection pool
store.WarmPool(maxConns)
return store, nil return store, nil
} }
// WarmPool pre-creates connections to avoid cold start latency.
func (s *Store) WarmPool(numConns int) {
if numConns <= 0 {
numConns = 4
}
var wg sync.WaitGroup
for i := 0; i < numConns; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
conn, err := s.sqlDB.Conn(ctx)
if err != nil {
return
}
// Execute a simple query to ensure the connection is fully initialized
_ = conn.PingContext(ctx)
// Return connection to pool (don't close it)
_ = conn.Close()
}()
}
wg.Wait()
log.Debug().Int("connections", numConns).Msg("Connection pool warmed")
}
// Close closes the database connection. // Close closes the database connection.
func (s *Store) Close() error { func (s *Store) Close() error {
return s.sqlDB.Close() return s.sqlDB.Close()
@@ -115,3 +167,370 @@ func (s *Store) GetRawDB() *sql.DB {
func (s *Store) GetDB() *gorm.DB { func (s *Store) GetDB() *gorm.DB {
return s.DB return s.DB
} }
// Stats returns database connection pool statistics.
func (s *Store) Stats() sql.DBStats {
return s.sqlDB.Stats()
}
// Optimize runs VACUUM and ANALYZE to optimize the database.
// Should be called periodically (e.g., daily) during low activity.
func (s *Store) Optimize(ctx context.Context) error {
log.Info().Msg("Starting database optimization")
start := time.Now()
// ANALYZE updates statistics for query optimizer
if _, err := s.sqlDB.ExecContext(ctx, "ANALYZE"); err != nil {
return fmt.Errorf("analyze: %w", err)
}
// PRAGMA optimize runs optimization based on query statistics
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA optimize"); err != nil {
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
}
log.Info().Dur("duration", time.Since(start)).Msg("Database optimization complete")
return nil
}
// HealthCheck performs a comprehensive health check with latency measurement.
// Returns detailed health information including connection pool stats and query latency.
// Results are cached for healthCacheTTL (default 5 seconds) to reduce database load
// from frequent monitoring calls.
func (s *Store) HealthCheck(ctx context.Context) *HealthInfo {
// Fast path: check cache with read lock
s.healthCacheMu.RLock()
if s.cachedHealth != nil && time.Since(s.healthCacheTime) < s.healthCacheTTL {
cached := s.cachedHealth
s.healthCacheMu.RUnlock()
return cached
}
s.healthCacheMu.RUnlock()
// Slow path: perform actual health check
info := s.performHealthCheck(ctx)
// Cache the result
s.healthCacheMu.Lock()
s.cachedHealth = info
s.healthCacheTime = time.Now()
s.healthCacheMu.Unlock()
return info
}
// HealthCheckForce performs a health check bypassing the cache.
// Use this when you need real-time health data (e.g., debugging, alerting).
func (s *Store) HealthCheckForce(ctx context.Context) *HealthInfo {
info := s.performHealthCheck(ctx)
// Update the cache with fresh data
s.healthCacheMu.Lock()
s.cachedHealth = info
s.healthCacheTime = time.Now()
s.healthCacheMu.Unlock()
return info
}
// performHealthCheck does the actual health check work.
func (s *Store) performHealthCheck(ctx context.Context) *HealthInfo {
info := &HealthInfo{
Status: "healthy",
Timestamp: time.Now(),
}
// Check pool stats
stats := s.sqlDB.Stats()
info.PoolStats = PoolStats{
OpenConnections: stats.OpenConnections,
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration,
MaxIdleClosed: stats.MaxIdleClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
}
// Record pool stats for metrics tracking
if s.metrics != nil {
s.metrics.RecordPoolStats(stats)
}
// Measure query latency with a simple SELECT
start := time.Now()
var dummy int
err := s.sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&dummy)
info.QueryLatency = time.Since(start)
// Record latency for historical tracking
if s.metrics != nil {
s.metrics.RecordLatency(info.QueryLatency)
info.HistoricalMetrics = s.metrics.GetMetricsSummary()
}
if err != nil {
info.Status = "unhealthy"
info.Error = err.Error()
return info
}
// Check for connection saturation (degraded if pool is heavily used)
if stats.InUse > 0 && float64(stats.InUse)/float64(stats.OpenConnections) > 0.8 {
info.Status = "degraded"
info.Warning = "Connection pool heavily utilized"
}
// Check for wait contention
if stats.WaitCount > 100 && stats.WaitDuration > 100*time.Millisecond {
info.Status = "degraded"
info.Warning = "Connection pool contention detected"
}
// Check query latency (warn if > 10ms for simple query)
if info.QueryLatency > 10*time.Millisecond {
if info.Status == "healthy" {
info.Status = "degraded"
}
info.Warning = fmt.Sprintf("Slow query latency: %v", info.QueryLatency)
}
// Check historical latency trend (degraded if P95 is high)
if s.metrics != nil && info.HistoricalMetrics.P95Latency > 50*time.Millisecond {
if info.Status == "healthy" {
info.Status = "degraded"
}
info.Warning = fmt.Sprintf("High P95 latency: %v", info.HistoricalMetrics.P95Latency)
}
return info
}
// HealthInfo contains database health check results.
type HealthInfo struct {
Status string `json:"status"` // healthy, degraded, unhealthy
Timestamp time.Time `json:"timestamp"`
QueryLatency time.Duration `json:"query_latency_ns"`
PoolStats PoolStats `json:"pool_stats"`
HistoricalMetrics MetricsSummary `json:"historical_metrics,omitempty"`
Error string `json:"error,omitempty"`
Warning string `json:"warning,omitempty"`
}
// PoolStats contains connection pool statistics.
type PoolStats struct {
OpenConnections int `json:"open_connections"`
InUse int `json:"in_use"`
Idle int `json:"idle"`
WaitCount int64 `json:"wait_count"`
WaitDuration time.Duration `json:"wait_duration_ns"`
MaxIdleClosed int64 `json:"max_idle_closed"`
MaxLifetimeClosed int64 `json:"max_lifetime_closed"`
}
// QueryTimeout constants for different query types.
const (
// DefaultQueryTimeout is the default timeout for regular queries.
DefaultQueryTimeout = 5 * time.Second
// FastQueryTimeout is for queries that should be very fast (health checks, etc).
FastQueryTimeout = 1 * time.Second
// SlowQueryTimeout is for queries that may take longer (bulk operations, rebuilds).
SlowQueryTimeout = 30 * time.Second
)
// PoolMetrics tracks historical connection pool metrics with a sliding window.
type PoolMetrics struct {
mu sync.RWMutex
latencySamples []time.Duration // Circular buffer of latency samples
latencyIdx int // Current index in circular buffer
latencyCount int // Number of samples collected
totalQueries int64 // Total queries executed
totalWaitTime time.Duration // Cumulative wait time for connections
peakInUse int // Peak concurrent connections in use
peakWaitCount int64 // Peak wait count observed
lastSampleTime time.Time // Last time a sample was recorded
windowSize int // Size of sliding window
}
// NewPoolMetrics creates a new pool metrics collector with the given window size.
func NewPoolMetrics(windowSize int) *PoolMetrics {
if windowSize <= 0 {
windowSize = 100 // Default: track last 100 samples
}
return &PoolMetrics{
latencySamples: make([]time.Duration, windowSize),
windowSize: windowSize,
lastSampleTime: time.Now(),
}
}
// RecordLatency records a query latency sample.
func (m *PoolMetrics) RecordLatency(latency time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.latencySamples[m.latencyIdx] = latency
m.latencyIdx = (m.latencyIdx + 1) % m.windowSize
if m.latencyCount < m.windowSize {
m.latencyCount++
}
m.totalQueries++
m.lastSampleTime = time.Now()
}
// RecordPoolStats records pool statistics for peak tracking.
func (m *PoolMetrics) RecordPoolStats(stats sql.DBStats) {
m.mu.Lock()
defer m.mu.Unlock()
if stats.InUse > m.peakInUse {
m.peakInUse = stats.InUse
}
if stats.WaitCount > m.peakWaitCount {
m.peakWaitCount = stats.WaitCount
}
m.totalWaitTime += stats.WaitDuration
}
// GetMetricsSummary returns a summary of collected metrics.
func (m *PoolMetrics) GetMetricsSummary() MetricsSummary {
m.mu.RLock()
defer m.mu.RUnlock()
summary := MetricsSummary{
TotalQueries: m.totalQueries,
SampleCount: m.latencyCount,
PeakInUse: m.peakInUse,
PeakWaitCount: m.peakWaitCount,
TotalWaitTime: m.totalWaitTime,
LastSampleTime: m.lastSampleTime,
}
if m.latencyCount == 0 {
return summary
}
// Calculate latency statistics
var total time.Duration
var min, max time.Duration = m.latencySamples[0], m.latencySamples[0]
for i := 0; i < m.latencyCount; i++ {
sample := m.latencySamples[i]
total += sample
if sample < min {
min = sample
}
if sample > max {
max = sample
}
}
summary.AvgLatency = total / time.Duration(m.latencyCount)
summary.MinLatency = min
summary.MaxLatency = max
// Calculate P95 latency (approximate using sorted samples)
if m.latencyCount >= 20 {
// Copy samples for sorting
samples := make([]time.Duration, m.latencyCount)
copy(samples, m.latencySamples[:m.latencyCount])
// Use slices.Sort for O(n log n) instead of O(n²) insertion sort
slices.Sort(samples)
p95Idx := int(float64(len(samples)) * 0.95)
summary.P95Latency = samples[p95Idx]
}
return summary
}
// MetricsSummary contains aggregated pool metrics.
type MetricsSummary struct {
TotalQueries int64 `json:"total_queries"`
SampleCount int `json:"sample_count"`
AvgLatency time.Duration `json:"avg_latency_ns"`
MinLatency time.Duration `json:"min_latency_ns"`
MaxLatency time.Duration `json:"max_latency_ns"`
P95Latency time.Duration `json:"p95_latency_ns,omitempty"`
PeakInUse int `json:"peak_in_use"`
PeakWaitCount int64 `json:"peak_wait_count"`
TotalWaitTime time.Duration `json:"total_wait_time_ns"`
LastSampleTime time.Time `json:"last_sample_time"`
}
// GetMetrics returns the current metrics without performing a health check.
func (s *Store) GetMetrics() MetricsSummary {
if s.metrics == nil {
return MetricsSummary{}
}
return s.metrics.GetMetricsSummary()
}
// ResetMetrics resets the metrics collector (useful for testing or after major changes).
func (s *Store) ResetMetrics() {
if s.metrics != nil {
s.metrics = NewPoolMetrics(s.metrics.windowSize)
}
}
// WithTimeout wraps a context with the given timeout and logs slow queries.
// Returns the wrapped context and a cancel function that should be called when done.
func (s *Store) WithTimeout(ctx context.Context, timeout time.Duration, operation string) (context.Context, context.CancelFunc) {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
start := time.Now()
// Return wrapped cancel that logs if query was slow
return timeoutCtx, func() {
elapsed := time.Since(start)
cancel()
// Log slow queries (> 100ms)
if elapsed > 100*time.Millisecond {
log.Warn().
Str("operation", operation).
Dur("elapsed", elapsed).
Dur("timeout", timeout).
Msg("Slow database operation")
}
}
}
// ExecWithTimeout executes a raw SQL query with timeout.
// Returns error if query takes longer than timeout.
func (s *Store) ExecWithTimeout(ctx context.Context, timeout time.Duration, query string, args ...any) error {
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "exec")
defer cancel()
_, err := s.sqlDB.ExecContext(timeoutCtx, query, args...)
if err != nil {
if err == context.DeadlineExceeded {
return fmt.Errorf("query timeout after %v: %s", timeout, query)
}
return err
}
return nil
}
// QueryRowWithTimeout executes a row query with timeout.
func (s *Store) QueryRowWithTimeout(ctx context.Context, timeout time.Duration, query string, args ...any) *sql.Row {
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "query_row")
// Note: cancel will be called when row.Scan() completes or errors
_ = cancel // Caller must ensure proper cleanup
return s.sqlDB.QueryRowContext(timeoutCtx, query, args...)
}
// TransactionWithTimeout wraps a transaction function with timeout handling.
// The transaction is automatically rolled back if the context times out.
func (s *Store) TransactionWithTimeout(ctx context.Context, timeout time.Duration, fn func(*gorm.DB) error) error {
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "transaction")
defer cancel()
return s.DB.WithContext(timeoutCtx).Transaction(func(tx *gorm.DB) error {
// Check context before proceeding
select {
case <-timeoutCtx.Done():
return timeoutCtx.Err()
default:
}
return fn(tx)
})
}
+292
View File
@@ -0,0 +1,292 @@
// Package maintenance provides scheduled maintenance tasks for claude-mnemonic.
package maintenance
import (
"context"
"sync"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/rs/zerolog"
)
// Service handles scheduled maintenance tasks.
type Service struct {
store *gorm.Store
observationStore *gorm.ObservationStore
summaryStore *gorm.SummaryStore
promptStore *gorm.PromptStore
vectorCleanupFn func(ctx context.Context, deletedIDs []int64)
config *config.Config
log zerolog.Logger
stopCh chan struct{}
doneCh chan struct{}
mu sync.Mutex
running bool
// Metrics
lastRunTime time.Time
lastRunDuration time.Duration
totalCleanedObs int64
totalOptimizeRun int64
}
// NewService creates a new maintenance service.
func NewService(
store *gorm.Store,
observationStore *gorm.ObservationStore,
summaryStore *gorm.SummaryStore,
promptStore *gorm.PromptStore,
vectorCleanupFn func(ctx context.Context, deletedIDs []int64),
cfg *config.Config,
log zerolog.Logger,
) *Service {
return &Service{
store: store,
observationStore: observationStore,
summaryStore: summaryStore,
promptStore: promptStore,
vectorCleanupFn: vectorCleanupFn,
config: cfg,
log: log.With().Str("component", "maintenance").Logger(),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// Start begins the maintenance loop.
func (s *Service) Start(ctx context.Context) {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
close(s.doneCh)
}()
if !s.config.MaintenanceEnabled {
s.log.Info().Msg("Maintenance disabled, not starting scheduler")
return
}
interval := max(time.Duration(s.config.MaintenanceIntervalHours)*time.Hour, time.Hour)
s.log.Info().
Dur("interval", interval).
Int("retention_days", s.config.ObservationRetentionDays).
Bool("cleanup_stale", s.config.CleanupStaleObservations).
Msg("Starting maintenance scheduler")
// Initial run after 5 minutes (allow system to stabilize)
time.Sleep(5 * time.Minute)
s.runMaintenance(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
s.log.Info().Msg("Maintenance shutting down due to context cancellation")
return
case <-s.stopCh:
s.log.Info().Msg("Maintenance shutting down due to stop signal")
return
case <-ticker.C:
s.runMaintenance(ctx)
}
}
}
// Stop signals the maintenance service to stop.
func (s *Service) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
close(s.stopCh)
}
// Wait waits for the maintenance service to finish.
func (s *Service) Wait() {
<-s.doneCh
}
// runMaintenance executes all maintenance tasks.
func (s *Service) runMaintenance(ctx context.Context) {
start := time.Now()
s.log.Info().Msg("Starting maintenance run")
var totalCleaned int64
// Task 1: Clean up old observations by age
if s.config.ObservationRetentionDays > 0 {
cleaned, err := s.cleanupOldObservations(ctx)
if err != nil {
s.log.Error().Err(err).Msg("Failed to cleanup old observations")
} else {
totalCleaned += cleaned
s.log.Info().Int64("cleaned", cleaned).Msg("Cleaned old observations by age")
}
}
// Task 2: Clean up stale observations
if s.config.CleanupStaleObservations {
cleaned, err := s.cleanupStaleObservations(ctx)
if err != nil {
s.log.Error().Err(err).Msg("Failed to cleanup stale observations")
} else {
totalCleaned += cleaned
s.log.Info().Int64("cleaned", cleaned).Msg("Cleaned stale observations")
}
}
// Task 3: Optimize database
if err := s.store.Optimize(ctx); err != nil {
s.log.Error().Err(err).Msg("Failed to optimize database")
} else {
s.totalOptimizeRun++
}
// Task 4: Clean up old prompts (keep last 1000 per session)
cleanedPrompts, err := s.cleanupOldPrompts(ctx)
if err != nil {
s.log.Error().Err(err).Msg("Failed to cleanup old prompts")
} else if cleanedPrompts > 0 {
s.log.Info().Int64("cleaned", cleanedPrompts).Msg("Cleaned old prompts")
}
// Update metrics
s.mu.Lock()
s.lastRunTime = time.Now()
s.lastRunDuration = time.Since(start)
s.totalCleanedObs += totalCleaned
s.mu.Unlock()
s.log.Info().
Dur("duration", time.Since(start)).
Int64("observations_cleaned", totalCleaned).
Msg("Maintenance run completed")
}
// cleanupOldObservations deletes observations older than the retention period.
func (s *Service) cleanupOldObservations(ctx context.Context) (int64, error) {
cutoffEpoch := time.Now().AddDate(0, 0, -s.config.ObservationRetentionDays).Unix()
// Get IDs of old observations
var deletedIDs []int64
err := s.store.GetDB().WithContext(ctx).
Model(&gorm.Observation{}).
Where("created_at_epoch < ?", cutoffEpoch).
Pluck("id", &deletedIDs).Error
if err != nil {
return 0, err
}
if len(deletedIDs) == 0 {
return 0, nil
}
// Delete in batches to avoid long transactions
batchSize := 100
for i := 0; i < len(deletedIDs); i += batchSize {
end := min(i+batchSize, len(deletedIDs))
batch := deletedIDs[i:end]
if err := s.store.GetDB().WithContext(ctx).
Where("id IN ?", batch).
Delete(&gorm.Observation{}).Error; err != nil {
return int64(i), err
}
// Sync vector DB deletions
if s.vectorCleanupFn != nil {
s.vectorCleanupFn(ctx, batch)
}
}
return int64(len(deletedIDs)), nil
}
// cleanupStaleObservations deletes observations marked as stale.
func (s *Service) cleanupStaleObservations(ctx context.Context) (int64, error) {
// Get IDs of stale observations (is_superseded = true)
var deletedIDs []int64
err := s.store.GetDB().WithContext(ctx).
Model(&gorm.Observation{}).
Where("is_superseded = ?", true).
Pluck("id", &deletedIDs).Error
if err != nil {
return 0, err
}
if len(deletedIDs) == 0 {
return 0, nil
}
// Delete in batches
batchSize := 100
for i := 0; i < len(deletedIDs); i += batchSize {
end := min(i+batchSize, len(deletedIDs))
batch := deletedIDs[i:end]
if err := s.store.GetDB().WithContext(ctx).
Where("id IN ?", batch).
Delete(&gorm.Observation{}).Error; err != nil {
return int64(i), err
}
// Sync vector DB deletions
if s.vectorCleanupFn != nil {
s.vectorCleanupFn(ctx, batch)
}
}
return int64(len(deletedIDs)), nil
}
// cleanupOldPrompts removes old prompts keeping only the most recent per session.
func (s *Service) cleanupOldPrompts(ctx context.Context) (int64, error) {
// Delete prompts older than 30 days that aren't the most recent in their session
cutoffEpoch := time.Now().AddDate(0, 0, -30).Unix()
result := s.store.GetDB().WithContext(ctx).
Where("created_at_epoch < ?", cutoffEpoch).
Delete(&gorm.UserPrompt{})
return result.RowsAffected, result.Error
}
// Stats returns maintenance statistics.
func (s *Service) Stats() map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
return map[string]any{
"enabled": s.config.MaintenanceEnabled,
"interval_hours": s.config.MaintenanceIntervalHours,
"retention_days": s.config.ObservationRetentionDays,
"cleanup_stale": s.config.CleanupStaleObservations,
"last_run": s.lastRunTime,
"last_duration_ms": s.lastRunDuration.Milliseconds(),
"total_cleaned_obs": s.totalCleanedObs,
"total_optimizes": s.totalOptimizeRun,
"running": s.running,
}
}
// RunNow triggers an immediate maintenance run.
func (s *Service) RunNow(ctx context.Context) {
go s.runMaintenance(ctx)
}
+2640 -45
View File
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -24,7 +24,7 @@ func TestServerSuite(t *testing.T) {
// TestNewServer tests server creation. // TestNewServer tests server creation.
func (s *ServerSuite) TestNewServer() { func (s *ServerSuite) TestNewServer() {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
s.NotNil(server) s.NotNil(server)
s.Nil(server.searchMgr) s.Nil(server.searchMgr)
s.Equal("1.0.0", server.version) s.Equal("1.0.0", server.version)
@@ -293,7 +293,7 @@ func TestTimelineParams(t *testing.T) {
// TestHandleInitialize tests the initialize handler. // TestHandleInitialize tests the initialize handler.
func TestHandleInitialize(t *testing.T) { func TestHandleInitialize(t *testing.T) {
server := NewServer(nil, "1.2.3", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.2.3", nil, nil, nil, nil, nil, nil, nil, nil)
req := &Request{ req := &Request{
JSONRPC: "2.0", JSONRPC: "2.0",
@@ -320,7 +320,7 @@ func TestHandleInitialize(t *testing.T) {
// TestHandleToolsList tests the tools/list handler. // TestHandleToolsList tests the tools/list handler.
func TestHandleToolsList(t *testing.T) { func TestHandleToolsList(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
req := &Request{ req := &Request{
JSONRPC: "2.0", JSONRPC: "2.0",
@@ -361,7 +361,7 @@ func TestHandleToolsList(t *testing.T) {
// TestHandleRequest tests request routing. // TestHandleRequest tests request routing.
func TestHandleRequest(t *testing.T) { func TestHandleRequest(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
tests := []struct { tests := []struct {
@@ -423,7 +423,7 @@ func TestHandleRequest(t *testing.T) {
// TestHandleToolsCall_InvalidParams tests tools/call with invalid params. // TestHandleToolsCall_InvalidParams tests tools/call with invalid params.
func TestHandleToolsCall_InvalidParams(t *testing.T) { func TestHandleToolsCall_InvalidParams(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
req := &Request{ req := &Request{
@@ -442,7 +442,7 @@ func TestHandleToolsCall_InvalidParams(t *testing.T) {
// TestCallTool_UnknownTool tests callTool with unknown tool name. // TestCallTool_UnknownTool tests callTool with unknown tool name.
func TestCallTool_UnknownTool(t *testing.T) { func TestCallTool_UnknownTool(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
_, err := server.callTool(ctx, "nonexistent_tool", json.RawMessage(`{}`)) _, err := server.callTool(ctx, "nonexistent_tool", json.RawMessage(`{}`))
@@ -452,7 +452,7 @@ func TestCallTool_UnknownTool(t *testing.T) {
// TestCallTool_InvalidArgs tests callTool with invalid arguments. // TestCallTool_InvalidArgs tests callTool with invalid arguments.
func TestCallTool_InvalidArgs(t *testing.T) { func TestCallTool_InvalidArgs(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
_, err := server.callTool(ctx, "search", json.RawMessage(`invalid json`)) _, err := server.callTool(ctx, "search", json.RawMessage(`invalid json`))
@@ -574,7 +574,7 @@ func TestJSONRPCErrorCodes(t *testing.T) {
// TestToolListContainsExpectedSchemas tests that tool schemas are valid. // TestToolListContainsExpectedSchemas tests that tool schemas are valid.
func TestToolListContainsExpectedSchemas(t *testing.T) { func TestToolListContainsExpectedSchemas(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
req := &Request{ req := &Request{
JSONRPC: "2.0", JSONRPC: "2.0",
@@ -600,7 +600,7 @@ func TestToolListContainsExpectedSchemas(t *testing.T) {
// TestHandleToolsCall_UnknownTool tests tools/call with unknown tool name. // TestHandleToolsCall_UnknownTool tests tools/call with unknown tool name.
func TestHandleToolsCall_UnknownTool(t *testing.T) { func TestHandleToolsCall_UnknownTool(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
req := &Request{ req := &Request{
@@ -620,7 +620,7 @@ func TestHandleToolsCall_UnknownTool(t *testing.T) {
func TestCallTool_ToolNameRecognition(t *testing.T) { func TestCallTool_ToolNameRecognition(t *testing.T) {
// Note: This test verifies tool routing logic, not execution (which requires searchMgr) // Note: This test verifies tool routing logic, not execution (which requires searchMgr)
// All valid tool names should be in the handleToolsList response // All valid tool names should be in the handleToolsList response
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
req := &Request{ req := &Request{
JSONRPC: "2.0", JSONRPC: "2.0",
@@ -782,7 +782,7 @@ func TestResponseIDTypes(t *testing.T) {
// TestHandleTimelineByQuery_EmptyQuery tests timeline by query with empty query. // TestHandleTimelineByQuery_EmptyQuery tests timeline by query with empty query.
func TestHandleTimelineByQuery_EmptyQuery(t *testing.T) { func TestHandleTimelineByQuery_EmptyQuery(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
// Empty query should error // Empty query should error
@@ -793,7 +793,7 @@ func TestHandleTimelineByQuery_EmptyQuery(t *testing.T) {
// TestHandleTimeline_InvalidJSON tests timeline with invalid JSON. // TestHandleTimeline_InvalidJSON tests timeline with invalid JSON.
func TestHandleTimeline_InvalidJSON(t *testing.T) { func TestHandleTimeline_InvalidJSON(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
_, err := server.handleTimeline(ctx, json.RawMessage(`{invalid`)) _, err := server.handleTimeline(ctx, json.RawMessage(`{invalid`))
@@ -803,7 +803,7 @@ func TestHandleTimeline_InvalidJSON(t *testing.T) {
// TestHandleTimelineByQuery_InvalidJSON tests timeline by query with invalid JSON. // TestHandleTimelineByQuery_InvalidJSON tests timeline by query with invalid JSON.
func TestHandleTimelineByQuery_InvalidJSON(t *testing.T) { func TestHandleTimelineByQuery_InvalidJSON(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
_, err := server.handleTimelineByQuery(ctx, json.RawMessage(`{invalid`)) _, err := server.handleTimelineByQuery(ctx, json.RawMessage(`{invalid`))
@@ -813,7 +813,7 @@ func TestHandleTimelineByQuery_InvalidJSON(t *testing.T) {
// TestHandleTimeline_NoAnchorNoQuery tests timeline with no anchor and no query. // TestHandleTimeline_NoAnchorNoQuery tests timeline with no anchor and no query.
func TestHandleTimeline_NoAnchorNoQuery(t *testing.T) { func TestHandleTimeline_NoAnchorNoQuery(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
// No anchor_id and no query should return empty result // No anchor_id and no query should return empty result
@@ -825,7 +825,7 @@ func TestHandleTimeline_NoAnchorNoQuery(t *testing.T) {
// TestHandleTimeline_WithDefaults tests timeline default values are applied. // TestHandleTimeline_WithDefaults tests timeline default values are applied.
func TestHandleTimeline_WithDefaults(t *testing.T) { func TestHandleTimeline_WithDefaults(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
// With anchor_id but no before/after, defaults should be applied // With anchor_id but no before/after, defaults should be applied
@@ -839,7 +839,7 @@ func TestHandleTimeline_WithDefaults(t *testing.T) {
// TestServerFields tests Server struct fields. // TestServerFields tests Server struct fields.
func TestServerFields(t *testing.T) { func TestServerFields(t *testing.T) {
server := NewServer(nil, "2.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "2.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
assert.Equal(t, "2.0.0", server.version) assert.Equal(t, "2.0.0", server.version)
assert.Nil(t, server.searchMgr) assert.Nil(t, server.searchMgr)
@@ -891,7 +891,7 @@ func TestErrorWithNilData(t *testing.T) {
// TestToolInputSchema tests that tool input schemas have required fields. // TestToolInputSchema tests that tool input schemas have required fields.
func TestToolInputSchema(t *testing.T) { func TestToolInputSchema(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
req := &Request{ req := &Request{
JSONRPC: "2.0", JSONRPC: "2.0",
@@ -960,7 +960,7 @@ func TestToolCallParamsWithComplexArgs(t *testing.T) {
// TestCallTool_UnknownToolName tests callTool with various unknown tool names. // TestCallTool_UnknownToolName tests callTool with various unknown tool names.
func TestCallTool_UnknownToolName(t *testing.T) { func TestCallTool_UnknownToolName(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
unknownTools := []string{ unknownTools := []string{
@@ -1009,7 +1009,7 @@ func TestTimelineParams_Validation(t *testing.T) {
// TestHandleToolsCall_UnknownToolNameError tests tools/call with unknown tool returns error. // TestHandleToolsCall_UnknownToolNameError tests tools/call with unknown tool returns error.
func TestHandleToolsCall_UnknownToolNameError(t *testing.T) { func TestHandleToolsCall_UnknownToolNameError(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
req := &Request{ req := &Request{
@@ -1031,7 +1031,7 @@ func TestHandleToolsCall_UnknownToolNameError(t *testing.T) {
// TestHandleToolsCall_EmptyParams tests tools/call with empty params. // TestHandleToolsCall_EmptyParams tests tools/call with empty params.
func TestHandleToolsCall_EmptyParams(t *testing.T) { func TestHandleToolsCall_EmptyParams(t *testing.T) {
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil) server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
ctx := context.Background() ctx := context.Background()
req := &Request{ req := &Request{
+67 -5
View File
@@ -3,6 +3,8 @@ package pattern
import ( import (
"context" "context"
"sort"
"strings"
"sync" "sync"
"time" "time"
@@ -21,6 +23,8 @@ type DetectorConfig struct {
AnalysisInterval time.Duration AnalysisInterval time.Duration
// MaxPatternsToTrack is the maximum number of active patterns. // MaxPatternsToTrack is the maximum number of active patterns.
MaxPatternsToTrack int MaxPatternsToTrack int
// MaxCandidates is the maximum number of candidates to track (LRU eviction).
MaxCandidates int
} }
// DefaultConfig returns the default detector configuration. // DefaultConfig returns the default detector configuration.
@@ -30,6 +34,7 @@ func DefaultConfig() DetectorConfig {
MinFrequencyForPattern: 2, // At least 2 occurrences to form a pattern MinFrequencyForPattern: 2, // At least 2 occurrences to form a pattern
AnalysisInterval: 5 * time.Minute, AnalysisInterval: 5 * time.Minute,
MaxPatternsToTrack: 1000, MaxPatternsToTrack: 1000,
MaxCandidates: 500, // Prevent unbounded growth
} }
} }
@@ -201,7 +206,24 @@ func (d *Detector) AnalyzeObservation(ctx context.Context, obs *models.Observati
} }
} }
} else { } else {
// Create new candidate // Create new candidate - with immediate size check to prevent unbounded growth
// between periodic cleanups (which run every 5 minutes)
if d.config.MaxCandidates > 0 && len(d.candidates) >= d.config.MaxCandidates {
// Evict oldest candidate immediately rather than waiting for periodic cleanup
var oldestKey string
var oldestTime int64 = time.Now().UnixMilli()
for k, c := range d.candidates {
if c.lastSeenEpoch < oldestTime {
oldestTime = c.lastSeenEpoch
oldestKey = k
}
}
if oldestKey != "" {
delete(d.candidates, oldestKey)
log.Debug().Str("evicted_key", oldestKey).Msg("Evicted oldest candidate to make room")
}
}
patternType := models.DetectPatternType(obs.Concepts, obs.Title.String, obs.Narrative.String) patternType := models.DetectPatternType(obs.Concepts, obs.Title.String, obs.Narrative.String)
d.candidates[candidateKey] = &candidatePattern{ d.candidates[candidateKey] = &candidatePattern{
signature: signature, signature: signature,
@@ -305,17 +327,53 @@ func (d *Detector) AnalyzeRecentObservations(ctx context.Context) error {
return nil return nil
} }
// cleanupOldCandidates removes candidates that haven't been seen recently. // cleanupOldCandidates removes candidates that haven't been seen recently
// and enforces the max candidates limit using LRU eviction.
func (d *Detector) cleanupOldCandidates() { func (d *Detector) cleanupOldCandidates() {
d.candidatesMu.Lock() d.candidatesMu.Lock()
defer d.candidatesMu.Unlock() defer d.candidatesMu.Unlock()
threshold := time.Now().Add(-7 * 24 * time.Hour).UnixMilli() threshold := time.Now().Add(-7 * 24 * time.Hour).UnixMilli()
// First pass: remove expired candidates
for key, candidate := range d.candidates { for key, candidate := range d.candidates {
if candidate.lastSeenEpoch < threshold { if candidate.lastSeenEpoch < threshold {
delete(d.candidates, key) delete(d.candidates, key)
} }
} }
// Second pass: enforce max candidates limit using LRU eviction
if d.config.MaxCandidates > 0 && len(d.candidates) > d.config.MaxCandidates {
// Find oldest candidates to evict using O(n log n) sort instead of O(n²) selection sort
type keyAge struct {
key string
age int64
}
candidates := make([]keyAge, 0, len(d.candidates))
for k, c := range d.candidates {
candidates = append(candidates, keyAge{k, c.lastSeenEpoch})
}
// Sort by age ascending (oldest first) - O(n log n)
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].age < candidates[j].age
})
// Delete oldest entries
toEvict := len(d.candidates) - d.config.MaxCandidates
for i := 0; i < toEvict; i++ {
delete(d.candidates, candidates[i].key)
}
log.Debug().Int("evicted", toEvict).Int("remaining", len(d.candidates)).Msg("Evicted old pattern candidates (LRU)")
}
}
// CandidateCount returns the current number of pattern candidates.
func (d *Detector) CandidateCount() int {
d.candidatesMu.RLock()
defer d.candidatesMu.RUnlock()
return len(d.candidates)
} }
// GetPatternInsight returns a formatted insight string for a pattern. // GetPatternInsight returns a formatted insight string for a pattern.
@@ -340,11 +398,15 @@ func generateCandidateKey(signature []string) string {
if len(signature) == 0 { if len(signature) == 0 {
return "" return ""
} }
key := "" // Use strings.Builder to avoid O(n²) string concatenation
var b strings.Builder
// Pre-allocate: estimate average signature element is 10 chars + separator
b.Grow(len(signature) * 11)
for _, s := range signature { for _, s := range signature {
key += s + "|" b.WriteString(s)
b.WriteByte('|')
} }
return key return b.String()
} }
// generatePatternName creates a human-readable name for a pattern. // generatePatternName creates a human-readable name for a pattern.
+98
View File
@@ -0,0 +1,98 @@
// Package privacy provides utilities for protecting sensitive data.
package privacy
import (
"regexp"
"slices"
"strings"
)
// secretPatterns contains compiled regular expressions for detecting secrets.
// These patterns are designed to catch common secret formats with minimal false positives.
var secretPatterns = []*regexp.Regexp{
// API keys with common prefixes
regexp.MustCompile(`(?i)(api[_-]?key|apikey)\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?`),
// Passwords in configuration
regexp.MustCompile(`(?i)(password|passwd|pwd)\s*[:=]\s*['"][^'"]{8,}['"]`),
// Secret tokens
regexp.MustCompile(`(?i)(secret[_-]?key|secret[_-]?token|auth[_-]?token)\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?`),
// OpenAI API keys
regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`),
// Anthropic API keys
regexp.MustCompile(`sk-ant-[a-zA-Z0-9-]{20,}`),
// GitHub tokens
regexp.MustCompile(`gh[pous]_[a-zA-Z0-9]{36,}`),
regexp.MustCompile(`github_pat_[a-zA-Z0-9_]{22,}`),
// AWS keys
regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
regexp.MustCompile(`(?i)aws[_-]?secret[_-]?access[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9/+=]{40}['"]?`),
// Private keys (PEM format indicators)
regexp.MustCompile(`-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`),
// JWT tokens (base64.base64.base64 format)
regexp.MustCompile(`eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+`),
// Generic secret assignment patterns
regexp.MustCompile(`(?i)bearer\s+[a-zA-Z0-9_-]{20,}`),
}
// ContainsSecrets checks if the given text contains any patterns that look like secrets.
// Returns true if potential secrets are detected.
func ContainsSecrets(text string) bool {
if text == "" {
return false
}
for _, pattern := range secretPatterns {
if pattern.MatchString(text) {
return true
}
}
return false
}
// RedactSecrets replaces detected secrets with a redaction marker.
// This allows the text to be stored while protecting sensitive data.
func RedactSecrets(text string) string {
if text == "" {
return text
}
result := text
for _, pattern := range secretPatterns {
result = pattern.ReplaceAllStringFunc(result, func(match string) string {
// Preserve the key name, redact only the value
if idx := strings.Index(match, "="); idx != -1 {
return match[:idx+1] + "[REDACTED]"
}
if idx := strings.Index(match, ":"); idx != -1 {
return match[:idx+1] + "[REDACTED]"
}
// For standalone secrets, show just the prefix
if len(match) > 8 {
return match[:4] + "...[REDACTED]"
}
return "[REDACTED]"
})
}
return result
}
// SanitizeObservation checks multiple fields of an observation for secrets.
// Returns true if any secrets were found.
// This function is used as a validation gate before storing observations.
func SanitizeObservation(narrative string, facts []string) bool {
if ContainsSecrets(narrative) {
return true
}
return slices.ContainsFunc(facts, func(fact string) bool {
return ContainsSecrets(fact)
})
}
+213
View File
@@ -0,0 +1,213 @@
package privacy
import (
"testing"
)
func TestContainsSecrets(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "empty string",
input: "",
expected: false,
},
{
name: "normal text",
input: "This is just some regular text about a bug fix",
expected: false,
},
{
name: "API key pattern",
input: "api_key=abc123def456ghi789jkl012mno345pqr678",
expected: true,
},
{
name: "api-key with dash",
input: `api-key: "abc123def456ghi789jkl012mno"`,
expected: true,
},
{
name: "password in config",
input: `password="super_secret_password_123"`,
expected: true,
},
{
name: "OpenAI key format",
input: "sk-abc123def456ghi789jkl012mno345pqr678",
expected: true,
},
{
name: "Anthropic key format",
input: "sk-ant-api03-abc123def456ghi789jkl012mno345",
expected: true,
},
{
name: "GitHub PAT",
input: "ghp_1234567890abcdefghijklmnopqrstuvwxyz",
expected: true,
},
{
name: "GitHub PAT new format",
input: "github_pat_12ABCDEFGHIJ3456789abc_defghijklmno",
expected: true,
},
{
name: "AWS access key",
input: "AKIAIOSFODNN7EXAMPLE",
expected: true,
},
{
name: "Private key header",
input: "-----BEGIN RSA PRIVATE KEY-----",
expected: true,
},
{
name: "JWT token",
input: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
expected: true,
},
{
name: "bearer token",
input: "Bearer abc123def456ghi789jkl012mno345",
expected: true,
},
{
name: "secret_key in code",
input: `secret_key = "my_super_secret_token_here"`,
expected: true,
},
{
name: "short password is not detected",
input: `password="short"`,
expected: false, // Too short to trigger
},
{
name: "word password in sentence",
input: "The password field should be validated",
expected: false,
},
{
name: "word api in code",
input: "The API returns JSON data",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ContainsSecrets(tt.input)
if result != tt.expected {
t.Errorf("ContainsSecrets(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
func TestRedactSecrets(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty string",
input: "",
expected: "",
},
{
name: "no secrets",
input: "This is safe text",
expected: "This is safe text",
},
{
name: "API key gets redacted",
input: "api_key=abc123def456ghi789jkl012mno345pqr678",
expected: "api_key=[REDACTED]",
},
{
name: "OpenAI key gets redacted",
input: "The key is sk-abc123def456ghi789jkl012mno345pqr678",
expected: "The key is sk-a...[REDACTED]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RedactSecrets(tt.input)
if result != tt.expected {
t.Errorf("RedactSecrets(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestSanitizeObservation(t *testing.T) {
tests := []struct {
name string
narrative string
facts []string
expected bool
}{
{
name: "clean observation",
narrative: "Fixed a bug in the login flow",
facts: []string{"Users can now log in", "Session management improved"},
expected: false,
},
{
name: "secret in narrative",
narrative: "Set API key api_key=abc123def456ghi789jkl012mno345",
facts: []string{"Configuration updated"},
expected: true,
},
{
name: "secret in facts",
narrative: "Updated configuration",
facts: []string{"Added api_key=abc123def456ghi789jkl012mno345"},
expected: true,
},
{
name: "empty facts",
narrative: "Clean narrative",
facts: []string{},
expected: false,
},
{
name: "nil facts",
narrative: "Clean narrative",
facts: nil,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SanitizeObservation(tt.narrative, tt.facts)
if result != tt.expected {
t.Errorf("SanitizeObservation() = %v, want %v", result, tt.expected)
}
})
}
}
func BenchmarkContainsSecrets(b *testing.B) {
text := "This is a normal piece of text that does not contain any secrets or sensitive information"
b.ResetTimer()
for i := 0; i < b.N; i++ {
ContainsSecrets(text)
}
}
func BenchmarkContainsSecretsWithSecret(b *testing.B) {
text := "api_key=abc123def456ghi789jkl012mno345pqr678"
b.ResetTimer()
for i := 0; i < b.N; i++ {
ContainsSecrets(text)
}
}
+7 -36
View File
@@ -36,6 +36,13 @@ func NewCalculator(config *models.ScoringConfig) *Calculator {
// - ConceptContrib = sum(concept_weights) × concept_weight_factor // - ConceptContrib = sum(concept_weights) × concept_weight_factor
// - RetrievalContrib = log2(retrieval_count + 1) × 0.1 × retrieval_weight // - RetrievalContrib = log2(retrieval_count + 1) × 0.1 × retrieval_weight
func (c *Calculator) Calculate(obs *models.Observation, now time.Time) float64 { func (c *Calculator) Calculate(obs *models.Observation, now time.Time) float64 {
return c.CalculateComponents(obs, now).FinalScore
}
// CalculateComponents returns the individual components of the importance score.
// Useful for debugging and explaining scores to users.
// This is the core calculation method - Calculate() delegates to this.
func (c *Calculator) CalculateComponents(obs *models.Observation, now time.Time) ScoreComponents {
// 1. Get base type weight // 1. Get base type weight
typeWeight := models.TypeBaseScore(obs.Type) typeWeight := models.TypeBaseScore(obs.Type)
@@ -75,42 +82,6 @@ func (c *Calculator) Calculate(obs *models.Observation, now time.Time) float64 {
finalScore = c.config.MinScore finalScore = c.config.MinScore
} }
return finalScore
}
// CalculateComponents returns the individual components of the importance score.
// Useful for debugging and explaining scores to users.
func (c *Calculator) CalculateComponents(obs *models.Observation, now time.Time) ScoreComponents {
typeWeight := models.TypeBaseScore(obs.Type)
ageDays := now.Sub(time.UnixMilli(obs.CreatedAtEpoch)).Hours() / 24.0
if ageDays < 0 {
ageDays = 0
}
recencyDecay := math.Pow(0.5, ageDays/c.config.RecencyHalfLifeDays)
coreScore := 1.0 * typeWeight * recencyDecay
feedbackContrib := float64(obs.UserFeedback) * c.config.FeedbackWeight
conceptBoost := 0.0
for _, concept := range obs.Concepts {
if weight, ok := c.config.ConceptWeights[concept]; ok {
conceptBoost += weight
}
}
conceptContrib := conceptBoost * c.config.ConceptWeight
retrievalContrib := 0.0
if obs.RetrievalCount > 0 {
retrievalBoost := math.Log2(float64(obs.RetrievalCount)+1) * 0.1
retrievalContrib = retrievalBoost * c.config.RetrievalWeight
}
finalScore := coreScore + feedbackContrib + conceptContrib + retrievalContrib
if finalScore < c.config.MinScore {
finalScore = c.config.MinScore
}
return ScoreComponents{ return ScoreComponents{
TypeWeight: typeWeight, TypeWeight: typeWeight,
RecencyDecay: recencyDecay, RecencyDecay: recencyDecay,
+9 -14
View File
@@ -3,7 +3,9 @@ package expansion
import ( import (
"context" "context"
"math"
"regexp" "regexp"
"sort"
"strings" "strings"
"sync" "sync"
@@ -281,14 +283,10 @@ func (e *Expander) expandByVocabulary(ctx context.Context, query string, minSimi
return nil return nil
} }
// Sort by score (descending) using bubble sort // Sort by score (descending) using Go's standard sort - O(n log n)
for i := 0; i < len(similar)-1; i++ { sort.Slice(similar, func(i, j int) bool {
for j := i + 1; j < len(similar); j++ { return similar[i].score > similar[j].score
if similar[j].score > similar[i].score { })
similar[i], similar[j] = similar[j], similar[i]
}
}
}
// Create expansion by combining top similar terms with query // Create expansion by combining top similar terms with query
var expansions []ExpandedQuery var expansions []ExpandedQuery
@@ -427,16 +425,13 @@ func cosineSimilarity(a, b []float32) float64 {
return dot / (sqrt(normA) * sqrt(normB)) return dot / (sqrt(normA) * sqrt(normB))
} }
// sqrt is a simple square root implementation. // sqrt uses the standard math.Sqrt for better performance and accuracy.
// Returns 0 for non-positive values (original behavior for compatibility).
func sqrt(x float64) float64 { func sqrt(x float64) float64 {
if x <= 0 { if x <= 0 {
return 0 return 0
} }
z := x return math.Sqrt(x)
for i := 0; i < 10; i++ {
z = (z + x/z) / 2
}
return z
} }
// truncate truncates a string to maxLen characters. // truncate truncates a string to maxLen characters.
+679 -14
View File
@@ -3,19 +3,156 @@ package search
import ( import (
"context" "context"
"hash/fnv"
"regexp"
"sort"
"strconv"
"strings" "strings"
"sync"
"sync/atomic"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm" "github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec" "github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models" "github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
) )
// multiSpaceRegex matches multiple consecutive whitespace characters.
// Pre-compiled for performance in normalizeQuery.
var multiSpaceRegex = regexp.MustCompile(`\s+`)
// Search configuration constants.
const (
// Cache configuration
defaultCacheTTL = 30 * time.Second // Short TTL for freshness
defaultCacheMaxSize = 200 // Max cached results
cacheEvictionPercent = 10 // Evict 10% when cache is full
cacheEvictionThreshold = 80 // Start eviction scan at 80% capacity
// Latency tracking
latencyHistogramCap = 1000 // Max latency samples for histogram
slowQueryThresholdNs = 100 * 1e6 // 100ms threshold for slow query logging
// Query frequency tracking
maxFrequencyEntries = 1000 // Max queries to track for warming
frequencyEvictionBatch = 100 // Remove 10% when frequency map is full
staleQueryThreshold = 24 * time.Hour // Remove queries older than 24 hours
recentQueryWindow = time.Hour // Only consider queries from last hour for warming
// Cache warming configuration
cacheWarmingInitDelay = 30 * time.Second // Delay before starting warming
cacheWarmingInterval = 20 * time.Second // Run warming cycle every 20 seconds
frequencyCleanupInterval = 5 * time.Minute // Cleanup stale entries every 5 minutes
cacheCleanupInterval = time.Minute // Cleanup expired cache every minute
warmingQueryTimeout = 5 * time.Second // Timeout for warming queries
warmingBatchSize = 5 // Warm top 5 queries per cycle
minRecencyFactor = 0.1 // Minimum recency factor for scoring
// Default query limits
defaultQueryLimit = 20
maxQueryLimit = 100
defaultOrderBy = "date_desc"
// Truncation lengths
queryLogTruncateLen = 50 // Truncate query in logs
titleTruncateLen = 100 // Truncate titles in results
warmingLogTruncateLen = 30 // Truncate query in warming logs
)
// SearchMetrics tracks search performance statistics.
type SearchMetrics struct {
TotalSearches int64 // Total number of searches performed
VectorSearches int64 // Searches using vector search
FilterSearches int64 // Searches using filter/FTS search
TotalLatencyNs int64 // Cumulative latency in nanoseconds
VectorLatencyNs int64 // Cumulative vector search latency
FilterLatencyNs int64 // Cumulative filter search latency
CacheHits int64 // Number of result cache hits
CoalescedRequests int64 // Number of requests served via singleflight coalescing
SearchErrors int64 // Number of search errors
// Percentile tracking (approximate using reservoir sampling)
latencyHistogram []int64 // Recent latency samples
histogramMu sync.Mutex
}
// GetStats returns the current search statistics.
func (m *SearchMetrics) GetStats() map[string]any {
totalSearches := atomic.LoadInt64(&m.TotalSearches)
totalLatency := atomic.LoadInt64(&m.TotalLatencyNs)
vectorSearches := atomic.LoadInt64(&m.VectorSearches)
vectorLatency := atomic.LoadInt64(&m.VectorLatencyNs)
filterSearches := atomic.LoadInt64(&m.FilterSearches)
filterLatency := atomic.LoadInt64(&m.FilterLatencyNs)
avgLatencyMs := float64(0)
if totalSearches > 0 {
avgLatencyMs = float64(totalLatency) / float64(totalSearches) / 1e6
}
avgVectorLatencyMs := float64(0)
if vectorSearches > 0 {
avgVectorLatencyMs = float64(vectorLatency) / float64(vectorSearches) / 1e6
}
avgFilterLatencyMs := float64(0)
if filterSearches > 0 {
avgFilterLatencyMs = float64(filterLatency) / float64(filterSearches) / 1e6
}
return map[string]any{
"total_searches": totalSearches,
"vector_searches": vectorSearches,
"filter_searches": filterSearches,
"cache_hits": atomic.LoadInt64(&m.CacheHits),
"coalesced_requests": atomic.LoadInt64(&m.CoalescedRequests),
"search_errors": atomic.LoadInt64(&m.SearchErrors),
"avg_latency_ms": avgLatencyMs,
"avg_vector_latency_ms": avgVectorLatencyMs,
"avg_filter_latency_ms": avgFilterLatencyMs,
}
}
// Manager provides unified search across SQLite and sqlite-vec. // Manager provides unified search across SQLite and sqlite-vec.
type Manager struct { type Manager struct {
observationStore *gorm.ObservationStore observationStore *gorm.ObservationStore
summaryStore *gorm.SummaryStore summaryStore *gorm.SummaryStore
promptStore *gorm.PromptStore promptStore *gorm.PromptStore
vectorClient *sqlitevec.Client vectorClient *sqlitevec.Client
metrics *SearchMetrics
// Context for graceful shutdown of background goroutines
ctx context.Context
cancel context.CancelFunc
// Request coalescing for concurrent identical queries
searchGroup singleflight.Group
// Result cache for repeated queries (short TTL)
resultCache map[string]*cachedResult
resultCacheMu sync.RWMutex
cacheTTL time.Duration
cacheMaxSize int
// Query frequency tracking for cache warming
queryFrequency map[string]*queryFrequencyInfo
queryFrequencyMu sync.RWMutex
}
// queryFrequencyInfo tracks how often a query is used.
type queryFrequencyInfo struct {
params SearchParams
count int64
lastUsed time.Time
lastCached time.Time
}
// cachedResult stores a cached search result with expiry.
type cachedResult struct {
result *UnifiedSearchResult
expiresAt time.Time
} }
// NewManager creates a new search manager. // NewManager creates a new search manager.
@@ -25,12 +162,463 @@ func NewManager(
promptStore *gorm.PromptStore, promptStore *gorm.PromptStore,
vectorClient *sqlitevec.Client, vectorClient *sqlitevec.Client,
) *Manager { ) *Manager {
return &Manager{ ctx, cancel := context.WithCancel(context.Background())
m := &Manager{
observationStore: observationStore, observationStore: observationStore,
summaryStore: summaryStore, summaryStore: summaryStore,
promptStore: promptStore, promptStore: promptStore,
vectorClient: vectorClient, vectorClient: vectorClient,
metrics: &SearchMetrics{latencyHistogram: make([]int64, 0, latencyHistogramCap)},
ctx: ctx,
cancel: cancel,
resultCache: make(map[string]*cachedResult),
cacheTTL: defaultCacheTTL,
cacheMaxSize: defaultCacheMaxSize,
queryFrequency: make(map[string]*queryFrequencyInfo),
} }
// Start cache cleanup goroutine
go m.cleanupCacheLoop()
// Start cache warming goroutine
go m.cacheWarmingLoop()
return m
}
// Close stops background goroutines and cleans up resources.
func (m *Manager) Close() {
if m.cancel != nil {
m.cancel()
}
}
// cleanupCacheLoop periodically removes expired cache entries.
func (m *Manager) cleanupCacheLoop() {
ticker := time.NewTicker(cacheCleanupInterval)
defer ticker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C:
m.cleanupExpiredCache()
}
}
}
// cleanupExpiredCache removes expired entries from the cache.
func (m *Manager) cleanupExpiredCache() {
m.resultCacheMu.Lock()
defer m.resultCacheMu.Unlock()
now := time.Now()
for key, cached := range m.resultCache {
if now.After(cached.expiresAt) {
delete(m.resultCache, key)
}
}
}
// cacheWarmingLoop periodically warms the cache for frequently used queries.
func (m *Manager) cacheWarmingLoop() {
// Wait a bit before starting to allow system to stabilize
select {
case <-m.ctx.Done():
return
case <-time.After(cacheWarmingInitDelay):
}
warmingTicker := time.NewTicker(cacheWarmingInterval)
cleanupTicker := time.NewTicker(frequencyCleanupInterval)
defer warmingTicker.Stop()
defer cleanupTicker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-warmingTicker.C:
m.warmFrequentQueries()
case <-cleanupTicker.C:
m.cleanupStaleFrequencyEntries()
}
}
}
// cleanupStaleFrequencyEntries removes query frequency entries older than staleQueryThreshold.
// This prevents memory bloat from queries that haven't been used in a long time.
func (m *Manager) cleanupStaleFrequencyEntries() {
m.queryFrequencyMu.Lock()
now := time.Now()
var keysToDelete []string
for k, v := range m.queryFrequency {
if now.Sub(v.lastUsed) > staleQueryThreshold {
keysToDelete = append(keysToDelete, k)
}
}
for _, k := range keysToDelete {
delete(m.queryFrequency, k)
}
m.queryFrequencyMu.Unlock()
if len(keysToDelete) > 0 {
log.Debug().Int("removed", len(keysToDelete)).Msg("Cleaned up stale query frequency entries")
}
}
// warmFrequentQueries pre-executes frequently used queries to warm the cache.
func (m *Manager) warmFrequentQueries() {
m.queryFrequencyMu.RLock()
// Find top N most frequent queries that aren't recently cached
type queryScore struct {
key string
info *queryFrequencyInfo
score float64
}
candidates := make([]queryScore, 0, len(m.queryFrequency))
now := time.Now()
for key, info := range m.queryFrequency {
// Only consider queries used recently
if now.Sub(info.lastUsed) > recentQueryWindow {
continue
}
// Only warm if not recently cached (cache about to expire or already expired)
timeSinceLastCache := now.Sub(info.lastCached)
if timeSinceLastCache < m.cacheTTL/2 {
continue
}
// Score: frequency * recency factor
recencyFactor := 1.0 - (now.Sub(info.lastUsed).Seconds() / recentQueryWindow.Seconds())
if recencyFactor < minRecencyFactor {
recencyFactor = minRecencyFactor
}
score := float64(info.count) * recencyFactor
candidates = append(candidates, queryScore{key: key, info: info, score: score})
}
m.queryFrequencyMu.RUnlock()
// Sort by score descending using O(n log n) algorithm
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
// Warm top queries
warmCount := min(warmingBatchSize, len(candidates))
for i := range warmCount {
candidate := candidates[i]
ctx, cancel := context.WithTimeout(context.Background(), warmingQueryTimeout)
result, err := m.executeSearch(ctx, candidate.info.params)
cancel()
if err == nil && result != nil {
cacheKey := m.getCacheKey(candidate.info.params)
m.putInCache(cacheKey, result)
// Update last cached time
m.queryFrequencyMu.Lock()
if info, ok := m.queryFrequency[candidate.key]; ok {
info.lastCached = time.Now()
}
m.queryFrequencyMu.Unlock()
log.Debug().
Str("query", truncate(candidate.info.params.Query, warmingLogTruncateLen)).
Float64("score", candidate.score).
Msg("Cache warmed for frequent query")
}
}
}
// trackQueryFrequency records query usage for cache warming decisions.
func (m *Manager) trackQueryFrequency(params SearchParams) {
key := m.getCacheKey(params)
m.queryFrequencyMu.Lock()
if info, ok := m.queryFrequency[key]; ok {
info.count++
info.lastUsed = time.Now()
m.queryFrequencyMu.Unlock()
return // Fast path: no eviction needed
}
m.queryFrequency[key] = &queryFrequencyInfo{
params: params,
count: 1,
lastUsed: time.Now(),
}
// Limit frequency map size to prevent memory bloat
mapLen := len(m.queryFrequency)
if mapLen <= maxFrequencyEntries {
m.queryFrequencyMu.Unlock()
return // Fast path: no eviction needed
}
// Collect keys and times for eviction (still under lock, but fast)
type entry struct {
key string
lastUsed time.Time
}
entries := make([]entry, 0, mapLen)
for k, v := range m.queryFrequency {
entries = append(entries, entry{key: k, lastUsed: v.lastUsed})
}
m.queryFrequencyMu.Unlock()
// Sort outside lock to reduce contention (O(n log n))
sort.Slice(entries, func(i, j int) bool {
return entries[i].lastUsed.Before(entries[j].lastUsed)
})
// Collect keys to delete
deleteCount := min(frequencyEvictionBatch, len(entries))
keysToDelete := make([]string, deleteCount)
for i := range deleteCount {
keysToDelete[i] = entries[i].key
}
// Re-acquire lock only for deletion (brief critical section)
m.queryFrequencyMu.Lock()
for _, k := range keysToDelete {
delete(m.queryFrequency, k)
}
m.queryFrequencyMu.Unlock()
}
// RecentQuery represents a recently executed search query.
type RecentQuery struct {
Query string `json:"query"`
Project string `json:"project,omitempty"`
Type string `json:"type,omitempty"` // observations, sessions, prompts
Count int64 `json:"count"` // Number of times executed
LastUsed time.Time `json:"last_used"`
}
// GetRecentQueries returns the most recent search queries, sorted by last used time.
func (m *Manager) GetRecentQueries(limit int) []RecentQuery {
if limit <= 0 {
limit = defaultQueryLimit
}
if limit > maxQueryLimit {
limit = maxQueryLimit
}
m.queryFrequencyMu.RLock()
defer m.queryFrequencyMu.RUnlock()
// Collect all queries
queries := make([]RecentQuery, 0, len(m.queryFrequency))
for _, info := range m.queryFrequency {
queries = append(queries, RecentQuery{
Query: info.params.Query,
Project: info.params.Project,
Type: info.params.Type,
Count: info.count,
LastUsed: info.lastUsed,
})
}
// Sort by last used (most recent first)
sort.Slice(queries, func(i, j int) bool {
return queries[i].LastUsed.After(queries[j].LastUsed)
})
// Limit results
if len(queries) > limit {
queries = queries[:limit]
}
return queries
}
// GetFrequentQueries returns the most frequently used search queries.
func (m *Manager) GetFrequentQueries(limit int) []RecentQuery {
if limit <= 0 {
limit = defaultQueryLimit
}
if limit > maxQueryLimit {
limit = maxQueryLimit
}
m.queryFrequencyMu.RLock()
defer m.queryFrequencyMu.RUnlock()
// Only include queries used recently
now := time.Now()
queries := make([]RecentQuery, 0, len(m.queryFrequency))
for _, info := range m.queryFrequency {
if now.Sub(info.lastUsed) > recentQueryWindow {
continue
}
queries = append(queries, RecentQuery{
Query: info.params.Query,
Project: info.params.Project,
Type: info.params.Type,
Count: info.count,
LastUsed: info.lastUsed,
})
}
// Sort by count (highest first)
sort.Slice(queries, func(i, j int) bool {
return queries[i].Count > queries[j].Count
})
// Limit results
if len(queries) > limit {
queries = queries[:limit]
}
return queries
}
// normalizeQuery normalizes a search query for consistent cache keys.
// Converts to lowercase, trims whitespace, and collapses multiple spaces.
// Uses pre-compiled regex for O(n) performance instead of O(n*m) loop.
func normalizeQuery(query string) string {
// Lowercase for case-insensitive matching
query = strings.ToLower(query)
// Collapse multiple whitespace into single space using pre-compiled regex
query = multiSpaceRegex.ReplaceAllString(query, " ")
// Trim leading/trailing whitespace (after collapsing)
return strings.TrimSpace(query)
}
// getCacheKey generates a cache key from search params.
// Uses direct string concatenation instead of JSON marshal for better performance.
// Queries are normalized for consistent cache hits across whitespace variations.
func (m *Manager) getCacheKey(params SearchParams) string {
// Normalize query for consistent cache keys
normalizedQuery := normalizeQuery(params.Query)
// Hash directly without intermediate string allocation.
// FNV-64a is fast and collision-safe for cache keys.
h := fnv.New64a()
// Write each field directly to the hasher with separators
h.Write([]byte(normalizedQuery))
h.Write([]byte{'|'})
h.Write([]byte(params.Type))
h.Write([]byte{'|'})
h.Write([]byte(params.Project))
h.Write([]byte{'|'})
h.Write([]byte(params.ObsType))
h.Write([]byte{'|'})
h.Write([]byte(params.Concepts))
h.Write([]byte{'|'})
h.Write([]byte(params.Files))
h.Write([]byte{'|'})
h.Write([]byte(strconv.FormatInt(params.DateStart, 10)))
h.Write([]byte{'|'})
h.Write([]byte(strconv.FormatInt(params.DateEnd, 10)))
h.Write([]byte{'|'})
h.Write([]byte(params.OrderBy))
h.Write([]byte{'|'})
h.Write([]byte(strconv.Itoa(params.Limit)))
h.Write([]byte{'|'})
h.Write([]byte(strconv.Itoa(params.Offset)))
h.Write([]byte{'|'})
h.Write([]byte(params.Format))
h.Write([]byte{'|'})
h.Write([]byte(params.Scope))
h.Write([]byte{'|'})
if params.IncludeGlobal {
h.Write([]byte{'1'})
} else {
h.Write([]byte{'0'})
}
h.Write([]byte{'|'})
if params.ExcludeSuperseded {
h.Write([]byte{'1'})
} else {
h.Write([]byte{'0'})
}
return strconv.FormatUint(h.Sum64(), 36) // Base36 for compact representation
}
// getFromCache retrieves a result from cache if valid.
func (m *Manager) getFromCache(key string) (*UnifiedSearchResult, bool) {
m.resultCacheMu.RLock()
defer m.resultCacheMu.RUnlock()
if cached, ok := m.resultCache[key]; ok {
if time.Now().Before(cached.expiresAt) {
atomic.AddInt64(&m.metrics.CacheHits, 1)
return cached.result, true
}
}
return nil, false
}
// putInCache stores a result in the cache.
// Optimized to skip expensive scans when cache is below capacity threshold.
func (m *Manager) putInCache(key string, result *UnifiedSearchResult) {
m.resultCacheMu.Lock()
defer m.resultCacheMu.Unlock()
now := time.Now()
cacheLen := len(m.resultCache)
// Only scan for expired entries when at threshold+ capacity (amortized cleanup)
evictionThreshold := (m.cacheMaxSize * cacheEvictionThreshold) / 100
if cacheLen >= evictionThreshold {
// Evict expired entries
for k, v := range m.resultCache {
if now.After(v.expiresAt) {
delete(m.resultCache, k)
}
}
cacheLen = len(m.resultCache) // Update after eviction
}
// If still at capacity after removing expired, use simple FIFO-style eviction
// Go map iteration order is random, which provides good cache behavior
if cacheLen >= m.cacheMaxSize {
// Evict percentage using random-order iteration (O(n) single pass)
evictCount := max(m.cacheMaxSize*cacheEvictionPercent/100, 1)
evicted := 0
for k := range m.resultCache {
delete(m.resultCache, k)
evicted++
if evicted >= evictCount {
break
}
}
}
m.resultCache[key] = &cachedResult{
result: result,
expiresAt: now.Add(m.cacheTTL),
}
}
// Metrics returns the search metrics for monitoring.
func (m *Manager) Metrics() *SearchMetrics {
return m.metrics
}
// CacheStats returns current cache statistics.
func (m *Manager) CacheStats() map[string]any {
m.resultCacheMu.RLock()
cacheSize := len(m.resultCache)
m.resultCacheMu.RUnlock()
return map[string]any{
"size": cacheSize,
"max_size": m.cacheMaxSize,
"ttl_sec": m.cacheTTL.Seconds(),
}
}
// ClearCache clears the result cache. Useful for testing or after data changes.
func (m *Manager) ClearCache() {
m.resultCacheMu.Lock()
m.resultCache = make(map[string]*cachedResult)
m.resultCacheMu.Unlock()
} }
// SearchParams contains parameters for unified search. // SearchParams contains parameters for unified search.
@@ -62,7 +650,7 @@ type SearchResult struct {
Scope string `json:"scope,omitempty"` // "project" or "global" Scope string `json:"scope,omitempty"` // "project" or "global"
CreatedAt int64 `json:"created_at_epoch"` CreatedAt int64 `json:"created_at_epoch"`
Score float64 `json:"score,omitempty"` Score float64 `json:"score,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
} }
// UnifiedSearchResult contains the combined search results. // UnifiedSearchResult contains the combined search results.
@@ -73,17 +661,69 @@ type UnifiedSearchResult struct {
} }
// UnifiedSearch performs a unified search across all document types. // UnifiedSearch performs a unified search across all document types.
// Uses caching and request coalescing for optimal performance.
func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) { func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
start := time.Now()
defer func() {
latency := time.Since(start).Nanoseconds()
atomic.AddInt64(&m.metrics.TotalSearches, 1)
atomic.AddInt64(&m.metrics.TotalLatencyNs, latency)
// Sample latency for histogram (reservoir sampling)
m.metrics.histogramMu.Lock()
if len(m.metrics.latencyHistogram) < latencyHistogramCap {
m.metrics.latencyHistogram = append(m.metrics.latencyHistogram, latency)
}
m.metrics.histogramMu.Unlock()
// Log slow queries
if latency > slowQueryThresholdNs {
log.Warn().
Str("query", truncate(params.Query, queryLogTruncateLen)).
Dur("latency", time.Duration(latency)).
Str("type", params.Type).
Msg("Slow search query")
}
}()
if params.Limit <= 0 { if params.Limit <= 0 {
params.Limit = 20 params.Limit = defaultQueryLimit
} }
if params.Limit > 100 { if params.Limit > maxQueryLimit {
params.Limit = 100 params.Limit = maxQueryLimit
} }
if params.OrderBy == "" { if params.OrderBy == "" {
params.OrderBy = "date_desc" params.OrderBy = defaultOrderBy
} }
// Check cache first
cacheKey := m.getCacheKey(params)
if cached, ok := m.getFromCache(cacheKey); ok {
return cached, nil
}
// Use singleflight to coalesce concurrent identical requests
result, err, _ := m.searchGroup.Do(cacheKey, func() (any, error) {
return m.executeSearch(ctx, params)
})
if err != nil {
return nil, err
}
searchResult := result.(*UnifiedSearchResult)
// Cache the result
m.putInCache(cacheKey, searchResult)
// Track query frequency for cache warming
m.trackQueryFrequency(params)
return searchResult, nil
}
// executeSearch performs the actual search without caching/coalescing.
func (m *Manager) executeSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
// If query is provided and vector client is available, use vector search // If query is provided and vector client is available, use vector search
if params.Query != "" && m.vectorClient != nil && m.vectorClient.IsConnected() { if params.Query != "" && m.vectorClient != nil && m.vectorClient.IsConnected() {
return m.vectorSearch(ctx, params) return m.vectorSearch(ctx, params)
@@ -95,6 +735,13 @@ func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*Unif
// vectorSearch performs semantic search via sqlite-vec. // vectorSearch performs semantic search via sqlite-vec.
func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) { func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
start := time.Now()
defer func() {
latency := time.Since(start).Nanoseconds()
atomic.AddInt64(&m.metrics.VectorSearches, 1)
atomic.AddInt64(&m.metrics.VectorLatencyNs, latency)
}()
// Build where filter based on search type // Build where filter based on search type
var docType sqlitevec.DocType var docType sqlitevec.DocType
switch params.Type { switch params.Type {
@@ -110,6 +757,7 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
// Query sqlite-vec // Query sqlite-vec
vectorResults, err := m.vectorClient.Query(ctx, params.Query, params.Limit*2, where) vectorResults, err := m.vectorClient.Query(ctx, params.Query, params.Limit*2, where)
if err != nil { if err != nil {
atomic.AddInt64(&m.metrics.SearchErrors, 1)
// Fall back to filter search on error // Fall back to filter search on error
return m.filterSearch(ctx, params) return m.filterSearch(ctx, params)
} }
@@ -125,7 +773,9 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
if len(obsIDs) > 0 && (params.Type == "" || params.Type == "observations") { if len(obsIDs) > 0 && (params.Type == "" || params.Type == "observations") {
obs, err := m.observationStore.GetObservationsByIDs(ctx, obsIDs, params.OrderBy, 0) obs, err := m.observationStore.GetObservationsByIDs(ctx, obsIDs, params.OrderBy, 0)
if err == nil { if err != nil {
log.Warn().Err(err).Int("count", len(obsIDs)).Msg("Failed to fetch observations by IDs in vector search")
} else {
for _, o := range obs { for _, o := range obs {
// Skip superseded observations when requested // Skip superseded observations when requested
if params.ExcludeSuperseded && o.IsSuperseded { if params.ExcludeSuperseded && o.IsSuperseded {
@@ -138,7 +788,9 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
if len(summaryIDs) > 0 && (params.Type == "" || params.Type == "sessions") { if len(summaryIDs) > 0 && (params.Type == "" || params.Type == "sessions") {
summaries, err := m.summaryStore.GetSummariesByIDs(ctx, summaryIDs, params.OrderBy, 0) summaries, err := m.summaryStore.GetSummariesByIDs(ctx, summaryIDs, params.OrderBy, 0)
if err == nil { if err != nil {
log.Warn().Err(err).Int("count", len(summaryIDs)).Msg("Failed to fetch summaries by IDs in vector search")
} else {
for _, s := range summaries { for _, s := range summaries {
results = append(results, m.summaryToResult(s, params.Format)) results = append(results, m.summaryToResult(s, params.Format))
} }
@@ -147,7 +799,9 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
if len(promptIDs) > 0 && (params.Type == "" || params.Type == "prompts") { if len(promptIDs) > 0 && (params.Type == "" || params.Type == "prompts") {
prompts, err := m.promptStore.GetPromptsByIDs(ctx, promptIDs, params.OrderBy, 0) prompts, err := m.promptStore.GetPromptsByIDs(ctx, promptIDs, params.OrderBy, 0)
if err == nil { if err != nil {
log.Warn().Err(err).Int("count", len(promptIDs)).Msg("Failed to fetch prompts by IDs in vector search")
} else {
for _, p := range prompts { for _, p := range prompts {
results = append(results, m.promptToResult(p, params.Format)) results = append(results, m.promptToResult(p, params.Format))
} }
@@ -168,6 +822,13 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
// filterSearch performs structured filter search via SQLite. // filterSearch performs structured filter search via SQLite.
func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) { func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
start := time.Now()
defer func() {
latency := time.Since(start).Nanoseconds()
atomic.AddInt64(&m.metrics.FilterSearches, 1)
atomic.AddInt64(&m.metrics.FilterLatencyNs, latency)
}()
var results []SearchResult var results []SearchResult
// Search observations // Search observations
@@ -182,7 +843,9 @@ func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*Unifi
obs, err = m.observationStore.GetRecentObservations(ctx, params.Project, params.Limit) obs, err = m.observationStore.GetRecentObservations(ctx, params.Project, params.Limit)
} }
if err == nil { if err != nil {
log.Warn().Err(err).Str("project", params.Project).Msg("Failed to fetch observations in filter search")
} else {
for _, o := range obs { for _, o := range obs {
results = append(results, m.observationToResult(o, params.Format)) results = append(results, m.observationToResult(o, params.Format))
} }
@@ -192,7 +855,9 @@ func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*Unifi
// Search summaries // Search summaries
if params.Type == "" || params.Type == "sessions" { if params.Type == "" || params.Type == "sessions" {
summaries, err := m.summaryStore.GetRecentSummaries(ctx, params.Project, params.Limit) summaries, err := m.summaryStore.GetRecentSummaries(ctx, params.Project, params.Limit)
if err == nil { if err != nil {
log.Warn().Err(err).Str("project", params.Project).Msg("Failed to fetch summaries in filter search")
} else {
for _, s := range summaries { for _, s := range summaries {
results = append(results, m.summaryToResult(s, params.Format)) results = append(results, m.summaryToResult(s, params.Format))
} }
@@ -249,7 +914,7 @@ func (m *Manager) observationToResult(obs *models.Observation, format string) Se
Project: obs.Project, Project: obs.Project,
Scope: string(obs.Scope), Scope: string(obs.Scope),
CreatedAt: obs.CreatedAtEpoch, CreatedAt: obs.CreatedAtEpoch,
Metadata: map[string]interface{}{ Metadata: map[string]any{
"obs_type": string(obs.Type), "obs_type": string(obs.Type),
"scope": string(obs.Scope), "scope": string(obs.Scope),
}, },
@@ -275,7 +940,7 @@ func (m *Manager) summaryToResult(summary *models.SessionSummary, format string)
} }
if summary.Request.Valid { if summary.Request.Valid {
result.Title = truncate(summary.Request.String, 100) result.Title = truncate(summary.Request.String, titleTruncateLen)
} }
if format == "full" && summary.Learned.Valid { if format == "full" && summary.Learned.Valid {
@@ -293,7 +958,7 @@ func (m *Manager) promptToResult(prompt *models.UserPromptWithSession, format st
CreatedAt: prompt.CreatedAtEpoch, CreatedAt: prompt.CreatedAtEpoch,
} }
result.Title = truncate(prompt.PromptText, 100) result.Title = truncate(prompt.PromptText, titleTruncateLen)
if format == "full" { if format == "full" {
result.Content = prompt.PromptText result.Content = prompt.PromptText
+717 -26
View File
@@ -5,19 +5,117 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time"
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo" sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding" "github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
) )
// embeddingCacheEntry stores a cached embedding with its timestamp.
type embeddingCacheEntry struct {
embedding []float32
timestamp int64 // Unix nano
}
// resultCacheEntry stores cached query results.
type resultCacheEntry struct {
results []QueryResult
timestamp int64 // Unix nano
queryHash string // Hash of query + filters for validation
}
// Client provides vector operations via sqlite-vec. // Client provides vector operations via sqlite-vec.
type Client struct { type Client struct {
db *sql.DB db *sql.DB
embedSvc *embedding.Service embedSvc *embedding.Service
mu sync.Mutex
// Separate mutexes for read and write operations to reduce contention
writeMu sync.Mutex // Protects write operations (AddDocuments, DeleteDocuments)
readMu sync.RWMutex // Protects read operations (Query, Count)
// Embedding cache for query deduplication
queryCache map[string]embeddingCacheEntry
queryCacheMu sync.RWMutex
cacheMaxSize int
cacheTTLNano int64 // Cache TTL in nanoseconds
// Result cache for repeated searches
resultCache map[string]resultCacheEntry
resultCacheMu sync.RWMutex
resultCacheMaxSize int
resultCacheTTLNano int64 // Shorter TTL for results (data changes more often)
// Cache statistics
stats CacheStats
// Background cleanup control
stopCleanup chan struct{}
cleanupWg sync.WaitGroup
// Singleflight to deduplicate concurrent embedding computations
embeddingGroup singleflight.Group
}
// CacheStats tracks cache performance metrics using atomic counters for lock-free updates.
type CacheStats struct {
embeddingHits atomic.Int64
embeddingMisses atomic.Int64
resultHits atomic.Int64
resultMisses atomic.Int64
embeddingEvictions atomic.Int64
resultEvictions atomic.Int64
}
// CacheStatsSnapshot is the exported version of CacheStats for JSON marshaling.
type CacheStatsSnapshot struct {
EmbeddingHits int64 `json:"embedding_hits"`
EmbeddingMisses int64 `json:"embedding_misses"`
ResultHits int64 `json:"result_hits"`
ResultMisses int64 `json:"result_misses"`
EmbeddingEvictions int64 `json:"embedding_evictions"`
ResultEvictions int64 `json:"result_evictions"`
}
// HitRate returns the cache hit rate as a percentage.
func (s CacheStatsSnapshot) HitRate() float64 {
total := s.EmbeddingHits + s.EmbeddingMisses + s.ResultHits + s.ResultMisses
if total == 0 {
return 0
}
hits := s.EmbeddingHits + s.ResultHits
return float64(hits) / float64(total) * 100
}
// HitRate returns the cache hit rate as a percentage.
func (s *CacheStats) HitRate() float64 {
embHits := s.embeddingHits.Load()
embMisses := s.embeddingMisses.Load()
resHits := s.resultHits.Load()
resMisses := s.resultMisses.Load()
total := embHits + embMisses + resHits + resMisses
if total == 0 {
return 0
}
hits := embHits + resHits
return float64(hits) / float64(total) * 100
}
// Snapshot returns a copy of the current stats.
func (s *CacheStats) Snapshot() CacheStatsSnapshot {
return CacheStatsSnapshot{
EmbeddingHits: s.embeddingHits.Load(),
EmbeddingMisses: s.embeddingMisses.Load(),
ResultHits: s.resultHits.Load(),
ResultMisses: s.resultMisses.Load(),
EmbeddingEvictions: s.embeddingEvictions.Load(),
ResultEvictions: s.resultEvictions.Load(),
}
} }
// Config holds configuration for the client. // Config holds configuration for the client.
@@ -34,10 +132,23 @@ func NewClient(cfg Config, embedSvc *embedding.Service) (*Client, error) {
return nil, fmt.Errorf("embedding service required") return nil, fmt.Errorf("embedding service required")
} }
return &Client{ c := &Client{
db: cfg.DB, db: cfg.DB,
embedSvc: embedSvc, embedSvc: embedSvc,
}, nil queryCache: make(map[string]embeddingCacheEntry),
cacheMaxSize: 500, // Cache up to 500 query embeddings
cacheTTLNano: 5 * 60 * 1e9, // 5 minute TTL for embeddings
resultCache: make(map[string]resultCacheEntry),
resultCacheMaxSize: 200, // Cache up to 200 search results
resultCacheTTLNano: 60 * 1e9, // 1 minute TTL for results (shorter since data changes)
stopCleanup: make(chan struct{}),
}
// Start background cache cleanup goroutine
c.cleanupWg.Add(1)
go c.cacheCleanupLoop()
return c, nil
} }
// AddDocuments adds documents with their embeddings to the vector store. // AddDocuments adds documents with their embeddings to the vector store.
@@ -46,8 +157,8 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
return nil return nil
} }
c.mu.Lock() c.writeMu.Lock()
defer c.mu.Unlock() defer c.writeMu.Unlock()
// Generate embeddings for all documents // Generate embeddings for all documents
texts := make([]string, len(docs)) texts := make([]string, len(docs))
@@ -75,7 +186,10 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
} }
defer func() { defer func() {
if err != nil { if err != nil {
_ = tx.Rollback() if rbErr := tx.Rollback(); rbErr != nil {
// Rollback failure is serious - indicates potential data corruption risk
log.Error().Err(rbErr).Err(err).Msg("Failed to rollback transaction after error - data may be inconsistent")
}
} }
}() }()
@@ -118,6 +232,9 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
return fmt.Errorf("commit transaction: %w", err) return fmt.Errorf("commit transaction: %w", err)
} }
// Invalidate result cache since data changed
c.InvalidateResultCache()
log.Debug().Int("count", len(docs)).Str("model", modelVersion).Msg("Added documents to sqlite-vec") log.Debug().Int("count", len(docs)).Str("model", modelVersion).Msg("Added documents to sqlite-vec")
return nil return nil
} }
@@ -128,12 +245,12 @@ func (c *Client) DeleteDocuments(ctx context.Context, ids []string) error {
return nil return nil
} }
c.mu.Lock() c.writeMu.Lock()
defer c.mu.Unlock() defer c.writeMu.Unlock()
// Build placeholder string // Build placeholder string
placeholders := make([]string, len(ids)) placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids)) args := make([]any, len(ids))
for i, id := range ids { for i, id := range ids {
placeholders[i] = "?" placeholders[i] = "?"
args[i] = id args[i] = id
@@ -148,17 +265,25 @@ func (c *Client) DeleteDocuments(ctx context.Context, ids []string) error {
return fmt.Errorf("delete documents: %w", err) return fmt.Errorf("delete documents: %w", err)
} }
// Invalidate result cache since data changed
c.InvalidateResultCache()
log.Debug().Int("count", len(ids)).Msg("Deleted documents from sqlite-vec") log.Debug().Int("count", len(ids)).Msg("Deleted documents from sqlite-vec")
return nil return nil
} }
// Query performs a vector similarity search. // Query performs a vector similarity search.
func (c *Client) Query(ctx context.Context, query string, limit int, where map[string]any) ([]QueryResult, error) { func (c *Client) Query(ctx context.Context, query string, limit int, where map[string]any) ([]QueryResult, error) {
c.mu.Lock() // Build cache key from query + filters + limit
defer c.mu.Unlock() cacheKey := c.buildResultCacheKey(query, limit, where)
// Generate query embedding // Check result cache first
queryEmb, err := c.embedSvc.Embed(query) if results, ok := c.getResultFromCache(cacheKey); ok {
return results, nil
}
// Generate query embedding OUTSIDE the lock for better concurrency
queryEmb, err := c.getOrComputeEmbedding(query)
if err != nil { if err != nil {
return nil, fmt.Errorf("embed query: %w", err) return nil, fmt.Errorf("embed query: %w", err)
} }
@@ -169,9 +294,13 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
return nil, fmt.Errorf("serialize query embedding: %w", err) return nil, fmt.Errorf("serialize query embedding: %w", err)
} }
// Now acquire read lock for the actual DB query
c.readMu.RLock()
defer c.readMu.RUnlock()
// Build query with filters // Build query with filters
// vec0 supports WHERE clauses on metadata columns // vec0 supports WHERE clauses on metadata columns
args := []interface{}{queryBlob} args := []any{queryBlob}
sqlQuery := ` sqlQuery := `
SELECT SELECT
@@ -232,6 +361,9 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
return nil, fmt.Errorf("iterate rows: %w", err) return nil, fmt.Errorf("iterate rows: %w", err)
} }
// Cache the results
c.cacheResults(cacheKey, results)
log.Debug(). log.Debug().
Str("query", truncateString(query, 50)). Str("query", truncateString(query, 50)).
Int("results", len(results)). Int("results", len(results)).
@@ -245,11 +377,196 @@ func (c *Client) IsConnected() bool {
return c.db != nil return c.db != nil
} }
// Close is a no-op (db managed externally). // Close stops the background cleanup goroutine (db managed externally).
func (c *Client) Close() error { func (c *Client) Close() error {
// Signal cleanup goroutine to stop
close(c.stopCleanup)
// Wait for cleanup to finish
c.cleanupWg.Wait()
return nil return nil
} }
// cacheCleanupLoop periodically removes expired cache entries.
func (c *Client) cacheCleanupLoop() {
defer c.cleanupWg.Done()
ticker := time.NewTicker(30 * time.Second) // Cleanup every 30 seconds
defer ticker.Stop()
for {
select {
case <-c.stopCleanup:
return
case <-ticker.C:
c.cleanupExpiredCaches()
}
}
}
// cleanupExpiredCaches removes expired entries from both caches.
func (c *Client) cleanupExpiredCaches() {
now := time.Now().UnixNano()
var embeddingExpired, resultExpired int64
// Cleanup embedding cache
c.queryCacheMu.Lock()
for key, entry := range c.queryCache {
if now-entry.timestamp > c.cacheTTLNano {
delete(c.queryCache, key)
embeddingExpired++
}
}
c.queryCacheMu.Unlock()
// Cleanup result cache
c.resultCacheMu.Lock()
for key, entry := range c.resultCache {
if now-entry.timestamp > c.resultCacheTTLNano {
delete(c.resultCache, key)
resultExpired++
}
}
c.resultCacheMu.Unlock()
// Update stats atomically
if embeddingExpired > 0 || resultExpired > 0 {
c.stats.embeddingEvictions.Add(embeddingExpired)
c.stats.resultEvictions.Add(resultExpired)
log.Debug().
Int64("embedding_expired", embeddingExpired).
Int64("result_expired", resultExpired).
Msg("Cache cleanup completed")
}
}
// BatchQueryResult holds results from a batch query operation.
type BatchQueryResult struct {
Query string // Original query string
Results []QueryResult // Results for this query
Error error // Error if query failed
}
// QueryBatch performs multiple vector searches concurrently.
// Returns results in the same order as input queries.
// Uses a worker pool to limit concurrent queries.
func (c *Client) QueryBatch(ctx context.Context, queries []string, limit int, where map[string]any) []BatchQueryResult {
if len(queries) == 0 {
return nil
}
// Limit concurrency to avoid overwhelming the database
maxConcurrent := min(4, len(queries))
results := make([]BatchQueryResult, len(queries))
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for i, query := range queries {
wg.Add(1)
go func(idx int, q string) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
results[idx] = BatchQueryResult{
Query: q,
Error: ctx.Err(),
}
return
}
// Execute query
queryResults, err := c.Query(ctx, q, limit, where)
results[idx] = BatchQueryResult{
Query: q,
Results: queryResults,
Error: err,
}
}(i, query)
}
wg.Wait()
return results
}
// QueryMultiField searches across multiple fields for a single query.
// Combines results from different field types and deduplicates by document ID.
func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, docType string, project string) ([]QueryResult, error) {
// Generate embedding once
queryEmb, err := c.getOrComputeEmbedding(query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
// Serialize query embedding
queryBlob, err := sqlite_vec.SerializeFloat32(queryEmb)
if err != nil {
return nil, fmt.Errorf("serialize query embedding: %w", err)
}
c.readMu.RLock()
defer c.readMu.RUnlock()
// Query with field type aggregation - get best match per document
sqlQuery := `
WITH ranked_results AS (
SELECT
doc_id,
distance,
sqlite_id,
doc_type,
field_type,
project,
scope,
ROW_NUMBER() OVER (PARTITION BY sqlite_id ORDER BY distance ASC) as rn
FROM vectors
WHERE embedding MATCH ?
AND doc_type = ?
AND (project = ? OR scope = 'global')
)
SELECT doc_id, distance, sqlite_id, doc_type, field_type, project, scope
FROM ranked_results
WHERE rn = 1
ORDER BY distance
LIMIT ?
`
rows, err := c.db.QueryContext(ctx, sqlQuery, queryBlob, docType, project, limit)
if err != nil {
return nil, fmt.Errorf("query vectors: %w", err)
}
defer rows.Close()
// Pre-allocate with limit to avoid repeated slice growth
results := make([]QueryResult, 0, limit)
for rows.Next() {
var r QueryResult
var sqliteID int64
var docTypeVal, fieldType, projectVal, scope sql.NullString
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docTypeVal, &fieldType, &projectVal, &scope); err != nil {
return nil, fmt.Errorf("scan row: %w", err)
}
r.Similarity = DistanceToSimilarity(r.Distance)
r.Metadata = map[string]any{
"sqlite_id": float64(sqliteID),
"doc_type": docTypeVal.String,
"field_type": fieldType.String,
"project": projectVal.String,
"scope": scope.String,
}
results = append(results, r)
}
return results, rows.Err()
}
// truncateString truncates a string to maxLen characters. // truncateString truncates a string to maxLen characters.
func truncateString(s string, maxLen int) string { func truncateString(s string, maxLen int) string {
if len(s) <= maxLen { if len(s) <= maxLen {
@@ -260,8 +577,8 @@ func truncateString(s string, maxLen int) string {
// Count returns the total number of vectors in the store. // Count returns the total number of vectors in the store.
func (c *Client) Count(ctx context.Context) (int64, error) { func (c *Client) Count(ctx context.Context) (int64, error) {
c.mu.Lock() c.readMu.RLock()
defer c.mu.Unlock() defer c.readMu.RUnlock()
var count int64 var count int64
err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM vectors").Scan(&count) err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM vectors").Scan(&count)
@@ -281,8 +598,8 @@ func (c *Client) ModelVersion() string {
// - The vectors table is empty // - The vectors table is empty
// - Any vectors have a different model_version than the current model // - Any vectors have a different model_version than the current model
func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) { func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
c.mu.Lock() c.readMu.RLock()
defer c.mu.Unlock() defer c.readMu.RUnlock()
currentModel := c.embedSvc.Version() currentModel := c.embedSvc.Version()
@@ -329,8 +646,8 @@ type StaleVectorInfo struct {
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions. // GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
// This enables granular rebuild - only re-embedding documents that need updating. // This enables granular rebuild - only re-embedding documents that need updating.
func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error) { func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error) {
c.mu.Lock() c.readMu.RLock()
defer c.mu.Unlock() defer c.readMu.RUnlock()
currentModel := c.embedSvc.Version() currentModel := c.embedSvc.Version()
@@ -372,6 +689,134 @@ func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error)
return results, nil return results, nil
} }
// VectorHealthStats contains comprehensive health information about the vector store.
type VectorHealthStats struct {
TotalVectors int64 `json:"total_vectors"`
StaleVectors int64 `json:"stale_vectors"`
CurrentModel string `json:"current_model"`
NeedsRebuild bool `json:"needs_rebuild"`
RebuildReason string `json:"rebuild_reason,omitempty"`
CoverageByType map[string]int64 `json:"coverage_by_type"`
ModelVersions map[string]int64 `json:"model_versions"`
ProjectCounts map[string]int64 `json:"project_counts"`
EmbeddingCache CacheStatsSnapshot `json:"embedding_cache"`
}
// GetHealthStats returns comprehensive health statistics about the vector store.
func (c *Client) GetHealthStats(ctx context.Context) (*VectorHealthStats, error) {
c.readMu.RLock()
defer c.readMu.RUnlock()
stats := &VectorHealthStats{
CurrentModel: c.embedSvc.Version(),
CoverageByType: make(map[string]int64),
ModelVersions: make(map[string]int64),
ProjectCounts: make(map[string]int64),
EmbeddingCache: c.stats.Snapshot(),
}
// Get total count
err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM vectors").Scan(&stats.TotalVectors)
if err != nil {
return nil, fmt.Errorf("count total vectors: %w", err)
}
// Get stale count
err = c.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM vectors WHERE model_version != ? OR model_version IS NULL",
stats.CurrentModel,
).Scan(&stats.StaleVectors)
if err != nil {
return nil, fmt.Errorf("count stale vectors: %w", err)
}
// Check if rebuild needed
stats.NeedsRebuild, stats.RebuildReason = c.needsRebuildUnlocked(ctx, stats.CurrentModel)
// Get coverage by doc_type
rows, err := c.db.QueryContext(ctx, "SELECT doc_type, COUNT(*) FROM vectors GROUP BY doc_type")
if err != nil {
return nil, fmt.Errorf("query doc types: %w", err)
}
defer rows.Close()
for rows.Next() {
var docType sql.NullString
var count int64
if err := rows.Scan(&docType, &count); err != nil {
return nil, fmt.Errorf("scan doc type: %w", err)
}
if docType.Valid {
stats.CoverageByType[docType.String] = count
} else {
stats.CoverageByType["unknown"] = count
}
}
// Get model version distribution
rows2, err := c.db.QueryContext(ctx, "SELECT COALESCE(model_version, 'unknown'), COUNT(*) FROM vectors GROUP BY model_version")
if err != nil {
return nil, fmt.Errorf("query model versions: %w", err)
}
defer rows2.Close()
for rows2.Next() {
var version string
var count int64
if err := rows2.Scan(&version, &count); err != nil {
return nil, fmt.Errorf("scan model version: %w", err)
}
stats.ModelVersions[version] = count
}
// Get project counts (top 10)
rows3, err := c.db.QueryContext(ctx,
"SELECT COALESCE(project, 'global'), COUNT(*) FROM vectors GROUP BY project ORDER BY COUNT(*) DESC LIMIT 10")
if err != nil {
return nil, fmt.Errorf("query projects: %w", err)
}
defer rows3.Close()
for rows3.Next() {
var project string
var count int64
if err := rows3.Scan(&project, &count); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
stats.ProjectCounts[project] = count
}
return stats, nil
}
// needsRebuildUnlocked checks if rebuild is needed without acquiring lock (caller must hold lock).
func (c *Client) needsRebuildUnlocked(ctx context.Context, currentModel string) (bool, string) {
var totalCount int64
err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM vectors").Scan(&totalCount)
if err != nil {
return false, ""
}
if totalCount == 0 {
return true, "empty"
}
var staleCount int64
err = c.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM vectors WHERE model_version != ? OR model_version IS NULL",
currentModel,
).Scan(&staleCount)
if err != nil {
return false, ""
}
if staleCount > 0 {
return true, fmt.Sprintf("model_mismatch:%d", staleCount)
}
return false, ""
}
// DeleteVectorsByDocIDs removes vectors by their doc_ids. // DeleteVectorsByDocIDs removes vectors by their doc_ids.
// Used for granular rebuild - delete stale vectors before re-adding. // Used for granular rebuild - delete stale vectors before re-adding.
func (c *Client) DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) error { func (c *Client) DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) error {
@@ -379,12 +824,12 @@ func (c *Client) DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) err
return nil return nil
} }
c.mu.Lock() c.writeMu.Lock()
defer c.mu.Unlock() defer c.writeMu.Unlock()
// Build placeholder string // Build placeholder string
placeholders := make([]string, len(docIDs)) placeholders := make([]string, len(docIDs))
args := make([]interface{}, len(docIDs)) args := make([]any, len(docIDs))
for i, id := range docIDs { for i, id := range docIDs {
placeholders[i] = "?" placeholders[i] = "?"
args[i] = id args[i] = id
@@ -402,3 +847,249 @@ func (c *Client) DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) err
log.Debug().Int("count", len(docIDs)).Msg("Deleted stale vectors by doc_id") log.Debug().Int("count", len(docIDs)).Msg("Deleted stale vectors by doc_id")
return nil return nil
} }
// DeleteByObservationID removes all vectors associated with an observation ID.
// Vectors are stored with doc_ids that include the observation ID, e.g., "obs_123_narrative".
func (c *Client) DeleteByObservationID(ctx context.Context, obsID int64) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
// Vectors have doc_ids like "obs_123_narrative", "obs_123_facts_0", etc.
pattern := fmt.Sprintf("obs_%d_%%", obsID)
_, err := c.db.ExecContext(ctx, "DELETE FROM vectors WHERE doc_id LIKE ?", pattern)
if err != nil {
return fmt.Errorf("delete vectors for observation %d: %w", obsID, err)
}
log.Debug().Int64("observation_id", obsID).Msg("Deleted vectors for observation")
return nil
}
// getOrComputeEmbedding returns a cached embedding or computes a new one.
// Uses singleflight to prevent duplicate concurrent computations for the same query.
func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
now := time.Now().UnixNano()
// Check cache first (read lock)
c.queryCacheMu.RLock()
if entry, ok := c.queryCache[query]; ok {
if now-entry.timestamp < c.cacheTTLNano {
c.queryCacheMu.RUnlock()
c.stats.embeddingHits.Add(1)
return entry.embedding, nil
}
}
c.queryCacheMu.RUnlock()
// Cache miss - use singleflight to deduplicate concurrent embedding requests
result, err, _ := c.embeddingGroup.Do(query, func() (any, error) {
// Double-check cache inside singleflight (another goroutine may have just cached it)
c.queryCacheMu.RLock()
if entry, ok := c.queryCache[query]; ok {
if time.Now().UnixNano()-entry.timestamp < c.cacheTTLNano {
c.queryCacheMu.RUnlock()
return entry.embedding, nil
}
}
c.queryCacheMu.RUnlock()
// Record cache miss
c.stats.embeddingMisses.Add(1)
// Compute embedding
emb, err := c.embedSvc.Embed(query)
if err != nil {
return nil, err
}
// Store in cache (write lock)
c.queryCacheMu.Lock()
nowCache := time.Now().UnixNano()
// Evict old entries if cache is full or near capacity (80% threshold)
evictionThreshold := (c.cacheMaxSize * 8) / 10
if len(c.queryCache) >= evictionThreshold {
// Phase 1: Remove ALL expired entries first (not just 10%)
evicted := int64(0)
for k, v := range c.queryCache {
if nowCache-v.timestamp > c.cacheTTLNano {
delete(c.queryCache, k)
evicted++
}
}
// Phase 2: If still at capacity, evict 10% using random iteration (O(n) instead of O(n log n))
// Go map iteration order is randomized, providing good cache behavior without sorting
if len(c.queryCache) >= c.cacheMaxSize {
evictCount := max(c.cacheMaxSize/10, 1)
for k := range c.queryCache {
delete(c.queryCache, k)
evicted++
evictCount--
if evictCount <= 0 {
break
}
}
}
if evicted > 0 {
c.stats.embeddingEvictions.Add(evicted)
}
}
c.queryCache[query] = embeddingCacheEntry{
embedding: emb,
timestamp: nowCache,
}
c.queryCacheMu.Unlock()
return emb, nil
})
if err != nil {
return nil, err
}
return result.([]float32), nil
}
// ClearCache clears the embedding cache.
func (c *Client) ClearCache() {
c.queryCacheMu.Lock()
c.queryCache = make(map[string]embeddingCacheEntry)
c.queryCacheMu.Unlock()
}
// GetCacheStats returns comprehensive cache statistics.
func (c *Client) GetCacheStats() CacheStatsSnapshot {
return c.stats.Snapshot()
}
// CacheStats returns basic cache size info for backward compatibility.
// Deprecated: Use GetCacheStats() for comprehensive statistics.
func (c *Client) CacheStats() (size int, maxSize int) {
c.queryCacheMu.RLock()
size = len(c.queryCache)
c.queryCacheMu.RUnlock()
return size, c.cacheMaxSize
}
// EmbeddingCacheSize returns the current embedding cache size.
func (c *Client) EmbeddingCacheSize() int {
c.queryCacheMu.RLock()
defer c.queryCacheMu.RUnlock()
return len(c.queryCache)
}
// ResultCacheSize returns the current result cache size.
func (c *Client) ResultCacheSize() int {
c.resultCacheMu.RLock()
defer c.resultCacheMu.RUnlock()
return len(c.resultCache)
}
// buildResultCacheKey creates a unique key for caching query results.
// Uses strings.Builder to avoid intermediate allocations.
func (c *Client) buildResultCacheKey(query string, limit int, where map[string]any) string {
// Pre-allocate with typical key size to avoid reallocation
var b strings.Builder
b.Grow(len(query) + 32) // query + typical prefix/suffix overhead
b.WriteString("q:")
b.WriteString(query)
b.WriteString(":l:")
b.WriteString(strconv.Itoa(limit))
if docType, ok := where["doc_type"].(string); ok {
b.WriteString(":dt:")
b.WriteString(docType)
}
if project, ok := where["project"].(string); ok {
b.WriteString(":p:")
b.WriteString(project)
}
return b.String()
}
// getResultFromCache retrieves cached results if available and not expired.
func (c *Client) getResultFromCache(cacheKey string) ([]QueryResult, bool) {
now := time.Now().UnixNano()
c.resultCacheMu.RLock()
entry, ok := c.resultCache[cacheKey]
c.resultCacheMu.RUnlock()
if !ok {
c.stats.resultMisses.Add(1)
return nil, false
}
// Check if entry is expired
if now-entry.timestamp > c.resultCacheTTLNano {
c.stats.resultMisses.Add(1)
return nil, false
}
c.stats.resultHits.Add(1)
// Return a copy to prevent mutation
results := make([]QueryResult, len(entry.results))
copy(results, entry.results)
return results, true
}
// cacheResults stores query results in the cache.
func (c *Client) cacheResults(cacheKey string, results []QueryResult) {
now := time.Now().UnixNano()
c.resultCacheMu.Lock()
defer c.resultCacheMu.Unlock()
// Evict old entries if cache is full
if len(c.resultCache) >= c.resultCacheMaxSize {
// Two-phase eviction: (1) TTL-expired entries, (2) random if still over capacity
evicted := 0
targetSize := c.resultCacheMaxSize * 8 / 10 // Target 80% capacity
// Phase 1: Remove all TTL-expired entries
for k, v := range c.resultCache {
if now-v.timestamp > c.resultCacheTTLNano {
delete(c.resultCache, k)
evicted++
}
}
// Phase 2: If still over target, remove random entries until at target
if len(c.resultCache) >= targetSize {
evictCount := len(c.resultCache) - targetSize + 1
for k := range c.resultCache {
delete(c.resultCache, k)
evicted++
evictCount--
if evictCount <= 0 {
break
}
}
}
if evicted > 0 {
c.stats.resultEvictions.Add(int64(evicted))
}
}
// Make a copy of results to store
resultsCopy := make([]QueryResult, len(results))
copy(resultsCopy, results)
c.resultCache[cacheKey] = resultCacheEntry{
results: resultsCopy,
timestamp: now,
queryHash: cacheKey,
}
}
// InvalidateResultCache clears the result cache.
// Should be called after write operations that modify vectors.
func (c *Client) InvalidateResultCache() {
c.resultCacheMu.Lock()
c.resultCache = make(map[string]resultCacheEntry)
c.resultCacheMu.Unlock()
}
+184
View File
@@ -338,3 +338,187 @@ func (s *Sync) DeletePatterns(ctx context.Context, patternIDs []int64) error {
return nil return nil
} }
// BatchSyncConfig configures batch synchronization behavior.
type BatchSyncConfig struct {
BatchSize int // Number of items per batch (default: 50)
ProgressLogFreq int // Log progress every N items (default: 100)
}
// DefaultBatchSyncConfig returns sensible defaults for batch sync.
func DefaultBatchSyncConfig() BatchSyncConfig {
return BatchSyncConfig{
BatchSize: 50,
ProgressLogFreq: 100,
}
}
// BatchSyncObservations syncs multiple observations efficiently in batches.
// This reduces memory pressure during large rebuilds by processing in chunks.
func (s *Sync) BatchSyncObservations(ctx context.Context, observations []*models.Observation, cfg BatchSyncConfig) (synced int, errors int) {
if len(observations) == 0 {
return 0, 0
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = 50
}
if cfg.ProgressLogFreq <= 0 {
cfg.ProgressLogFreq = 100
}
for i := 0; i < len(observations); i += cfg.BatchSize {
// Check context cancellation
select {
case <-ctx.Done():
log.Warn().Int("synced", synced).Int("remaining", len(observations)-i).Msg("Batch sync cancelled")
return synced, errors
default:
}
end := min(i+cfg.BatchSize, len(observations))
batch := observations[i:end]
var docs []Document
// Collect all documents for this batch
for _, obs := range batch {
docs = append(docs, s.formatObservationDocs(obs)...)
}
// Add all documents in one call
if len(docs) > 0 {
if err := s.client.AddDocuments(ctx, docs); err != nil {
log.Warn().Err(err).Int("batchStart", i).Int("batchSize", len(batch)).Msg("Failed to sync observation batch")
errors += len(batch)
continue
}
}
synced += len(batch)
// Log progress periodically
if synced%cfg.ProgressLogFreq == 0 || synced == len(observations) {
log.Debug().Int("synced", synced).Int("total", len(observations)).Msg("Observation batch sync progress")
}
}
return synced, errors
}
// BatchSyncSummaries syncs multiple summaries efficiently in batches.
func (s *Sync) BatchSyncSummaries(ctx context.Context, summaries []*models.SessionSummary, cfg BatchSyncConfig) (synced int, errors int) {
if len(summaries) == 0 {
return 0, 0
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = 50
}
if cfg.ProgressLogFreq <= 0 {
cfg.ProgressLogFreq = 100
}
for i := 0; i < len(summaries); i += cfg.BatchSize {
// Check context cancellation
select {
case <-ctx.Done():
log.Warn().Int("synced", synced).Int("remaining", len(summaries)-i).Msg("Batch sync cancelled")
return synced, errors
default:
}
end := min(i+cfg.BatchSize, len(summaries))
batch := summaries[i:end]
var docs []Document
// Collect all documents for this batch
for _, summary := range batch {
docs = append(docs, s.formatSummaryDocs(summary)...)
}
// Add all documents in one call
if len(docs) > 0 {
if err := s.client.AddDocuments(ctx, docs); err != nil {
log.Warn().Err(err).Int("batchStart", i).Int("batchSize", len(batch)).Msg("Failed to sync summary batch")
errors += len(batch)
continue
}
}
synced += len(batch)
// Log progress periodically
if synced%cfg.ProgressLogFreq == 0 || synced == len(summaries) {
log.Debug().Int("synced", synced).Int("total", len(summaries)).Msg("Summary batch sync progress")
}
}
return synced, errors
}
// BatchSyncPrompts syncs multiple user prompts efficiently in batches.
func (s *Sync) BatchSyncPrompts(ctx context.Context, prompts []*models.UserPromptWithSession, cfg BatchSyncConfig) (synced int, errors int) {
if len(prompts) == 0 {
return 0, 0
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = 50
}
if cfg.ProgressLogFreq <= 0 {
cfg.ProgressLogFreq = 100
}
for i := 0; i < len(prompts); i += cfg.BatchSize {
// Check context cancellation
select {
case <-ctx.Done():
log.Warn().Int("synced", synced).Int("remaining", len(prompts)-i).Msg("Batch sync cancelled")
return synced, errors
default:
}
end := min(i+cfg.BatchSize, len(prompts))
batch := prompts[i:end]
docs := make([]Document, 0, len(batch))
// Collect all documents for this batch
for _, prompt := range batch {
docs = append(docs, Document{
ID: fmt.Sprintf("prompt_%d", prompt.ID),
Content: prompt.PromptText,
Metadata: map[string]any{
"sqlite_id": prompt.ID,
"doc_type": "user_prompt",
"sdk_session_id": prompt.SDKSessionID,
"project": prompt.Project,
"scope": "",
"created_at_epoch": prompt.CreatedAtEpoch,
"prompt_number": prompt.PromptNumber,
"field_type": "prompt",
},
})
}
// Add all documents in one call
if len(docs) > 0 {
if err := s.client.AddDocuments(ctx, docs); err != nil {
log.Warn().Err(err).Int("batchStart", i).Int("batchSize", len(batch)).Msg("Failed to sync prompt batch")
errors += len(batch)
continue
}
}
synced += len(batch)
// Log progress periodically
if synced%cfg.ProgressLogFreq == 0 || synced == len(prompts) {
log.Debug().Int("synced", synced).Int("total", len(prompts)).Msg("Prompt batch sync progress")
}
}
return synced, errors
}
+125 -1191
View File
File diff suppressed because it is too large Load Diff
+677
View File
@@ -0,0 +1,677 @@
// Package worker provides context and search-related HTTP handlers.
package worker
import (
"context"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/reranking"
"github.com/lukaszraczylo/claude-mnemonic/internal/search/expansion"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/sdk"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// handleSearchByPrompt searches observations relevant to a user prompt.
// IMPORTANT: This is on the critical startup path - must be fast!
// No synchronous verification - just filter by staleness and return.
func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
cwd := r.URL.Query().Get("cwd")
if project == "" || query == "" {
http.Error(w, "project and query required", http.StatusBadRequest)
return
}
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
limit := gorm.ParseLimitParamWithMax(r, DefaultSearchLimit, 200)
var observations []*models.Observation
var err error
var usedVector bool
similarityScores := make(map[int64]float64) // Track similarity per observation
// Get threshold settings from config
threshold := s.config.ContextRelevanceThreshold
maxResults := s.config.ContextMaxPromptResults
// Generate expanded queries if query expander is available
// Use timeout context to prevent query expansion from blocking
var expandedQueries []expansion.ExpandedQuery
var detectedIntent string
if s.queryExpander != nil {
expandCtx, expandCancel := context.WithTimeout(r.Context(), 5*time.Second)
cfg := expansion.DefaultConfig()
cfg.EnableVocabularyExpansion = false // Vocabulary expansion is optional
expandedQueries = s.queryExpander.Expand(expandCtx, query, cfg)
expandCancel() // Cancel immediately after use (defer not needed - no panic possible between creation and here)
if len(expandedQueries) > 0 {
detectedIntent = string(expandedQueries[0].Intent)
}
}
if len(expandedQueries) == 0 {
// Fallback to just the original query
expandedQueries = []expansion.ExpandedQuery{
{Query: query, Weight: 1.0, Source: "original"},
}
}
// Try vector search first if available
var vectorSearchFailed bool
if s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
// Search with each expanded query and merge results
// Pre-allocate with estimated capacity to avoid repeated reallocation
estimatedCapacity := len(expandedQueries) * limit * 2
allVectorResults := make([]sqlitevec.QueryResult, 0, estimatedCapacity)
queryWeights := make(map[string]float64, len(expandedQueries))
var vectorErrors int
for _, eq := range expandedQueries {
vectorResults, vecErr := s.vectorClient.Query(r.Context(), eq.Query, limit*2, where)
if vecErr != nil {
vectorErrors++
log.Debug().Err(vecErr).Str("query", eq.Query).Msg("Vector query failed")
} else if len(vectorResults) > 0 {
// Apply weight to similarity scores before merging
for i := range vectorResults {
vectorResults[i].Similarity *= eq.Weight
}
allVectorResults = append(allVectorResults, vectorResults...)
queryWeights[eq.Query] = eq.Weight
}
}
// Track if vector search had issues
if vectorErrors > 0 && vectorErrors == len(expandedQueries) {
vectorSearchFailed = true
log.Warn().Int("errors", vectorErrors).Str("project", project).Msg("All vector queries failed, falling back to FTS")
}
if len(allVectorResults) > 0 {
// Filter by relevance threshold before extracting IDs
// Use a slightly lower threshold for expanded queries
effectiveThreshold := threshold * 0.9 // Allow slightly lower scores for expanded queries
filteredResults := sqlitevec.FilterByThreshold(allVectorResults, effectiveThreshold, 0)
// Build similarity map for filtered results (keeping highest weighted score per observation)
for _, vr := range filteredResults {
if sqliteID, ok := vr.Metadata["sqlite_id"].(float64); ok {
id := int64(sqliteID)
// Keep the highest score for each observation
if existing, exists := similarityScores[id]; !exists || vr.Similarity > existing {
similarityScores[id] = vr.Similarity
}
}
}
// Extract observation IDs with project/scope filtering using shared helper
obsIDs := sqlitevec.ExtractObservationIDs(filteredResults, project)
if len(obsIDs) > 0 {
// Fetch full observations from SQLite
observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
}
}
}
// Fall back to FTS if vector search not available, failed, or returned no results
if !usedVector || len(observations) == 0 {
if vectorSearchFailed {
log.Info().Str("project", project).Msg("Using FTS fallback due to vector search failure")
}
observations, err = s.observationStore.SearchObservationsFTS(r.Context(), query, project, limit)
if err != nil {
// FTS might fail if query has special chars, try without
log.Warn().Err(err).Str("query", query).Msg("FTS search failed, falling back to recent")
observations, err = s.observationStore.GetRecentObservations(r.Context(), project, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
// Fast staleness filter - NO verification (that's too slow for interactive use)
// Just check mtimes and exclude obviously stale observations
var staleCount int
freshObservations := make([]*models.Observation, 0, len(observations))
for _, obs := range observations {
if len(obs.FileMtimes) > 0 && cwd != "" {
var paths []string
for path := range obs.FileMtimes {
paths = append(paths, path)
}
currentMtimes := sdk.GetFileMtimes(paths, cwd)
if obs.CheckStaleness(currentMtimes) {
// Stale - exclude but don't verify (too slow)
// Queue for background verification instead
staleCount++
s.queueStaleVerification(obs.ID, cwd)
continue
}
}
freshObservations = append(freshObservations, obs)
}
// Apply cross-encoder reranking if available
var reranked bool
if s.reranker != nil && len(freshObservations) > 0 && usedVector {
// Build candidates from observations with their bi-encoder scores
candidates := make([]reranking.Candidate, len(freshObservations))
for i, obs := range freshObservations {
// Use strings.Builder for efficient concatenation
var content string
if obs.Narrative.Valid && obs.Narrative.String != "" {
var sb strings.Builder
sb.Grow(len(obs.Title.String) + 1 + len(obs.Narrative.String))
sb.WriteString(obs.Title.String)
sb.WriteByte(' ')
sb.WriteString(obs.Narrative.String)
content = sb.String()
} else {
content = obs.Title.String
}
candidates[i] = reranking.Candidate{
ID: strconv.FormatInt(obs.ID, 10), // Faster than fmt.Sprintf
Content: content,
Score: similarityScores[obs.ID],
Metadata: map[string]any{"obs_idx": i},
}
}
// Rerank using cross-encoder - use pure mode or combined scores
var rerankResults []reranking.RerankResult
var rerankErr error
if s.config.RerankingPureMode {
rerankResults, rerankErr = s.reranker.RerankByScore(query, candidates, s.config.RerankingResults)
} else {
rerankResults, rerankErr = s.reranker.Rerank(query, candidates, s.config.RerankingResults)
}
if rerankErr != nil {
log.Warn().Err(rerankErr).Msg("Cross-encoder reranking failed, using original order")
} else if len(rerankResults) > 0 {
// Update similarity scores with reranked scores
for _, rr := range rerankResults {
if id, err := strconv.ParseInt(rr.ID, 10, 64); err == nil {
similarityScores[id] = rr.CombinedScore
}
}
// Reorder observations based on rerank results
reorderedObs := make([]*models.Observation, 0, len(rerankResults))
obsMap := make(map[int64]*models.Observation)
for _, obs := range freshObservations {
obsMap[obs.ID] = obs
}
for _, rr := range rerankResults {
if id, err := strconv.ParseInt(rr.ID, 10, 64); err == nil {
if obs, ok := obsMap[id]; ok {
reorderedObs = append(reorderedObs, obs)
}
}
}
freshObservations = reorderedObs
reranked = true
log.Debug().
Int("candidates", len(candidates)).
Int("returned", len(rerankResults)).
Msg("Cross-encoder reranking complete")
}
}
// Cluster similar observations to remove duplicates
clusteredObservations := clusterObservations(freshObservations, 0.4)
duplicatesRemoved := len(freshObservations) - len(clusteredObservations)
// Sort by similarity score (highest first) if we have scores and didn't rerank
if len(similarityScores) > 0 && len(clusteredObservations) > 0 && !reranked {
sort.Slice(clusteredObservations, func(i, j int) bool {
scoreI := similarityScores[clusteredObservations[i].ID]
scoreJ := similarityScores[clusteredObservations[j].ID]
return scoreI > scoreJ
})
}
// Apply max results cap if configured
if maxResults > 0 && len(clusteredObservations) > maxResults {
clusteredObservations = clusteredObservations[:maxResults]
}
// Record retrieval stats with staleness metrics
s.recordRetrievalStatsExtended(project, int64(len(clusteredObservations)), 0, 0,
int64(staleCount), int64(len(freshObservations)), int64(duplicatesRemoved), true)
// Increment retrieval counts for scoring (async, non-blocking)
if len(clusteredObservations) > 0 {
ids := make([]int64, len(clusteredObservations))
for i, obs := range clusteredObservations {
ids[i] = obs.ID
}
s.incrementRetrievalCounts(ids)
}
log.Info().
Str("project", project).
Str("query", query).
Str("intent", detectedIntent).
Int("expansions", len(expandedQueries)).
Int("found", len(clusteredObservations)).
Int("stale_excluded", staleCount).
Float64("threshold", threshold).
Msg("Prompt-based observation search")
// Build response with similarity scores
obsWithScores := make([]map[string]any, len(clusteredObservations))
for i, obs := range clusteredObservations {
obsMap := obs.ToMap()
if score, ok := similarityScores[obs.ID]; ok {
obsMap["similarity"] = score
}
obsWithScores[i] = obsMap
}
// Build expansion info for response
expansionInfo := make([]map[string]any, len(expandedQueries))
for i, eq := range expandedQueries {
expansionInfo[i] = map[string]any{
"query": eq.Query,
"weight": eq.Weight,
"source": eq.Source,
}
}
// Track this search for analytics
s.trackSearchQuery(query, project, "observations", len(clusteredObservations), usedVector)
writeJSON(w, map[string]any{
"project": project,
"query": query,
"intent": detectedIntent,
"expansions": expansionInfo,
"observations": obsWithScores,
"threshold": threshold,
"max_results": maxResults,
})
}
// handleFileContext returns observations relevant to specific files being worked on.
// Uses vector similarity search to find observations that mention or relate to the given files.
func (s *Service) handleFileContext(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "project required", http.StatusBadRequest)
return
}
filesParam := r.URL.Query().Get("files")
if filesParam == "" {
http.Error(w, "files required", http.StatusBadRequest)
return
}
// Parse comma-separated file paths
files := strings.Split(filesParam, ",")
if len(files) == 0 {
http.Error(w, "at least one file required", http.StatusBadRequest)
return
}
// Limit to reasonable number of files
maxFiles := 20
if len(files) > maxFiles {
files = files[:maxFiles]
}
// Get limit parameter (default 10 per file)
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 50 {
limit = parsed
}
}
// Search for observations related to each file in parallel
ctx := r.Context()
// Check if vector search is available
if s.vectorClient == nil || !s.vectorClient.IsConnected() {
writeJSON(w, map[string]any{
"files": files,
"results": map[string]any{},
"count": 0,
"error": "vector search not available",
})
return
}
// Prepare for parallel execution
type fileResult struct {
file string
results []map[string]any
obsIDs []int64 // Track observation IDs for deduplication
}
resultsChan := make(chan fileResult, len(files))
sem := make(chan struct{}, 5) // Limit concurrency to 5 parallel searches
var wg sync.WaitGroup
for _, file := range files {
file = strings.TrimSpace(file)
if file == "" {
continue
}
wg.Add(1)
go func(file string) {
defer wg.Done()
sem <- struct{}{} // Acquire semaphore
defer func() { <-sem }() // Release semaphore
// Build search query from file path
query := buildFileQuery(file)
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
vectorResults, vecErr := s.vectorClient.Query(ctx, query, limit*2, where)
if vecErr != nil {
log.Warn().Err(vecErr).Str("file", file).Msg("Vector search failed for file context")
return
}
// Extract observation IDs from vector results
obsIDs := sqlitevec.ExtractObservationIDs(vectorResults, project)
if len(obsIDs) == 0 {
return
}
// Fetch observations
observations, err := s.observationStore.GetObservationsByIDs(ctx, obsIDs, "score_desc", limit*2)
if err != nil {
log.Warn().Err(err).Str("file", file).Msg("Failed to fetch observations for file context")
return
}
// Pre-build score map from vector results (O(n) instead of O(n²))
scoreMap := make(map[int64]float64, len(vectorResults))
var avgScore float64
for _, vr := range vectorResults {
avgScore += vr.Similarity
// Parse observation ID from vector result ID (format: obs_{id}_{field})
// Use index-based parsing to avoid slice allocation from strings.Split
if len(vr.ID) > 4 && vr.ID[:4] == "obs_" {
rest := vr.ID[4:] // Skip "obs_"
underscoreIdx := strings.IndexByte(rest, '_')
var idStr string
if underscoreIdx >= 0 {
idStr = rest[:underscoreIdx]
} else {
idStr = rest
}
if id, parseErr := strconv.ParseInt(idStr, 10, 64); parseErr == nil {
// Keep highest score for each observation
if existing, exists := scoreMap[id]; !exists || vr.Similarity > existing {
scoreMap[id] = vr.Similarity
}
}
}
}
if len(vectorResults) > 0 {
avgScore /= float64(len(vectorResults))
}
fileResults := make([]map[string]any, 0, limit)
var usedIDs []int64
for _, obs := range observations {
// Check project scope
if obs.Scope == "project" && obs.Project != project {
continue
}
// O(1) score lookup instead of O(n) nested loop
score, found := scoreMap[obs.ID]
if !found {
// Use average score as fallback
score = avgScore
}
// Only include if score is above threshold
if score < 0.3 {
continue
}
fileResults = append(fileResults, map[string]any{
"id": obs.ID,
"title": obs.Title.String,
"type": obs.Type,
"narrative": obs.Narrative.String,
"facts": obs.Facts,
"score": score,
})
usedIDs = append(usedIDs, obs.ID)
if len(fileResults) >= limit {
break
}
}
if len(fileResults) > 0 {
resultsChan <- fileResult{file: file, results: fileResults, obsIDs: usedIDs}
}
}(file)
}
// Close channel when all goroutines complete
go func() {
wg.Wait()
close(resultsChan)
}()
// Collect results and deduplicate
allResults := make(map[string]any)
seenObservationIDs := make(map[int64]bool)
for res := range resultsChan {
// Filter out duplicates that were already seen in other files
dedupedResults := make([]map[string]any, 0, len(res.results))
for i, r := range res.results {
obsID := res.obsIDs[i]
if !seenObservationIDs[obsID] {
seenObservationIDs[obsID] = true
dedupedResults = append(dedupedResults, r)
}
}
if len(dedupedResults) > 0 {
allResults[res.file] = dedupedResults
}
}
writeJSON(w, map[string]any{
"files": files,
"results": allResults,
"count": len(allResults),
})
}
// buildFileQuery extracts meaningful search terms from a file path.
func buildFileQuery(filePath string) string {
// Remove common prefixes and extensions
path := strings.TrimPrefix(filePath, "/")
// Extract the filename and directory
parts := strings.Split(path, "/")
meaningful := make([]string, 0, len(parts))
for _, part := range parts {
// Skip common directory names that aren't meaningful
switch strings.ToLower(part) {
case "src", "lib", "internal", "pkg", "cmd", "api", "app", "test", "tests", "spec", "specs":
continue
default:
// Remove file extension
if idx := strings.LastIndex(part, "."); idx > 0 {
part = part[:idx]
}
// Convert camelCase/PascalCase to spaces
part = splitCamelCase(part)
// Convert snake_case to spaces
part = strings.ReplaceAll(part, "_", " ")
// Convert kebab-case to spaces
part = strings.ReplaceAll(part, "-", " ")
meaningful = append(meaningful, part)
}
}
return strings.Join(meaningful, " ")
}
// splitCamelCase splits camelCase or PascalCase into separate words.
func splitCamelCase(s string) string {
var result strings.Builder
for i, r := range s {
if i > 0 && r >= 'A' && r <= 'Z' {
result.WriteRune(' ')
}
result.WriteRune(r)
}
return result.String()
}
// handleContextInject returns context for injection at session start.
// IMPORTANT: This is on the critical startup path - must be fast!
// No synchronous verification - just filter by staleness and return.
func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "project required", http.StatusBadRequest)
return
}
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cwd := r.URL.Query().Get("cwd")
if cwd == "" {
cwd = "/"
}
// Limit observations for fast startup (configurable, default 100)
limit := s.config.ContextObservations
if limit <= 0 {
limit = DefaultContextLimit
}
// Full count determines how many observations get full detail (configurable, default 25)
fullCount := s.config.ContextFullCount
if fullCount <= 0 {
fullCount = 25
}
// Get recent observations
observations, err := s.observationStore.GetRecentObservations(r.Context(), project, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Fast staleness filter - NO verification (that's too slow for startup)
var staleCount int
freshObservations := make([]*models.Observation, 0, len(observations))
for _, obs := range observations {
if len(obs.FileMtimes) > 0 {
var paths []string
for path := range obs.FileMtimes {
paths = append(paths, path)
}
currentMtimes := sdk.GetFileMtimes(paths, cwd)
if obs.CheckStaleness(currentMtimes) {
// Stale - exclude but don't verify (too slow)
// Queue for background verification instead
staleCount++
s.queueStaleVerification(obs.ID, cwd)
continue
}
}
freshObservations = append(freshObservations, obs)
}
// Cluster similar observations to remove duplicates
clusteredObservations := clusterObservations(freshObservations, 0.4)
duplicatesRemoved := len(freshObservations) - len(clusteredObservations)
// Record retrieval stats with staleness metrics
s.recordRetrievalStatsExtended(project, int64(len(clusteredObservations)), 0, 0,
int64(staleCount), int64(len(freshObservations)), int64(duplicatesRemoved), false)
// Increment retrieval counts for scoring (async, non-blocking)
if len(clusteredObservations) > 0 {
ids := make([]int64, len(clusteredObservations))
for i, obs := range clusteredObservations {
ids[i] = obs.ID
}
s.incrementRetrievalCounts(ids)
}
log.Info().
Str("project", project).
Int("total", len(observations)).
Int("fresh", len(freshObservations)).
Int("clustered", len(clusteredObservations)).
Int("duplicates", duplicatesRemoved).
Int("stale_excluded", staleCount).
Msg("Context injection with clustering")
writeJSON(w, map[string]any{
"project": project,
"observations": clusteredObservations,
"full_count": fullCount,
"stale_excluded": staleCount,
"duplicates_removed": duplicatesRemoved,
})
}
// handleContextCount returns the count of observations for a project.
func (s *Service) handleContextCount(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "project required", http.StatusBadRequest)
return
}
count, err := s.getCachedObservationCount(r.Context(), project)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{
"project": project,
"count": count,
})
}
+595
View File
@@ -0,0 +1,595 @@
// Package worker provides data retrieval HTTP handlers.
package worker
import (
"encoding/json"
"net/http"
"runtime"
"sort"
"strings"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// handleGetObservations returns recent observations.
// Supports optional query parameter for semantic search via sqlite-vec.
// Supports pagination via limit and offset query parameters.
func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) {
pagination := gorm.ParsePaginationParams(r, DefaultObservationsLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var observations []*models.Observation
var total int64
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, pagination.Limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
obsIDs := sqlitevec.ExtractObservationIDs(vectorResults, project)
if len(obsIDs) > 0 {
observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", pagination.Limit)
if err == nil {
usedVector = true
total = int64(len(observations)) // Vector search doesn't have total, use returned count
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
// Strict project filtering for dashboard - only observations from this project
observations, total, err = s.observationStore.GetObservationsByProjectStrictPaginated(r.Context(), project, pagination.Limit, pagination.Offset)
} else {
// All projects
observations, total, err = s.observationStore.GetAllRecentObservationsPaginated(r.Context(), pagination.Limit, pagination.Offset)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if observations == nil {
observations = []*models.Observation{}
}
// Track search if query was provided
if query != "" {
s.trackSearchQuery(query, project, "observations", len(observations), usedVector)
}
// Return paginated response
writeJSON(w, map[string]any{
"observations": observations,
"total": total,
"limit": pagination.Limit,
"offset": pagination.Offset,
"hasMore": int64(pagination.Offset)+int64(len(observations)) < total,
})
}
// handleGetSummaries returns recent summaries.
// Supports optional query parameter for semantic search via sqlite-vec.
func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
limit := gorm.ParseLimitParam(r, DefaultSummariesLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var summaries []*models.SessionSummary
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeSessionSummary, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
summaryIDs := sqlitevec.ExtractSummaryIDs(vectorResults, project)
if len(summaryIDs) > 0 {
summaries, err = s.summaryStore.GetSummariesByIDs(r.Context(), summaryIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
summaries, err = s.summaryStore.GetRecentSummaries(r.Context(), project, limit)
} else {
summaries, err = s.summaryStore.GetAllRecentSummaries(r.Context(), limit)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if summaries == nil {
summaries = []*models.SessionSummary{}
}
writeJSON(w, summaries)
}
// handleGetPrompts returns recent user prompts.
// Supports optional query parameter for semantic search via sqlite-vec.
func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
limit := gorm.ParseLimitParam(r, DefaultPromptsLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var prompts []*models.UserPromptWithSession
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeUserPrompt, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
promptIDs := sqlitevec.ExtractPromptIDs(vectorResults, project)
if len(promptIDs) > 0 {
prompts, err = s.promptStore.GetPromptsByIDs(r.Context(), promptIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
prompts, err = s.promptStore.GetRecentUserPromptsByProject(r.Context(), project, limit)
} else {
prompts, err = s.promptStore.GetAllRecentUserPrompts(r.Context(), limit)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if prompts == nil {
prompts = []*models.UserPromptWithSession{}
}
writeJSON(w, prompts)
}
// handleGetProjects returns all projects.
// Response is cacheable for 5 minutes since project list changes infrequently.
func (s *Service) handleGetProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.sessionStore.GetAllProjects(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Cache for 5 minutes - project list changes infrequently
w.Header().Set("Cache-Control", "public, max-age=300")
writeJSON(w, projects)
}
// handleGetTypes returns the canonical list of observation and concept types.
// This provides a single source of truth for both backend and frontend.
// Response is cacheable as these values never change at runtime.
func (s *Service) handleGetTypes(w http.ResponseWriter, r *http.Request) {
// Cache for 24 hours - these values are compile-time constants
w.Header().Set("Cache-Control", "public, max-age=86400")
writeJSON(w, map[string]any{
"observation_types": ObservationTypes,
"concept_types": ConceptTypes,
})
}
// handleGetModels returns available embedding models.
// Response is cacheable as model list doesn't change without restart.
func (s *Service) handleGetModels(w http.ResponseWriter, _ *http.Request) {
// Cache for 1 hour - model list is static during runtime
w.Header().Set("Cache-Control", "public, max-age=3600")
models := embedding.ListModels()
defaultModel := embedding.GetDefaultModel()
writeJSON(w, map[string]any{
"models": models,
"default": defaultModel,
"current": s.embedSvc.Version(),
})
}
// handleGetStats returns worker statistics.
func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
retrievalStats := s.GetRetrievalStats(project)
sessionsToday, _ := s.sessionStore.GetSessionsToday(r.Context())
response := map[string]any{
"uptime": time.Since(s.startTime).String(),
"uptimeSeconds": time.Since(s.startTime).Seconds(),
"activeSessions": s.sessionManager.GetActiveSessionCount(),
"queueDepth": s.sessionManager.GetTotalQueueDepth(),
"isProcessing": s.sessionManager.IsAnySessionProcessing(),
"connectedClients": s.sseBroadcaster.ClientCount(),
"sessionsToday": sessionsToday,
"retrieval": retrievalStats,
"ready": s.ready.Load(),
}
// Add memory stats
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
response["memory"] = map[string]any{
"alloc_mb": float64(memStats.Alloc) / 1024 / 1024,
"total_alloc_mb": float64(memStats.TotalAlloc) / 1024 / 1024,
"sys_mb": float64(memStats.Sys) / 1024 / 1024,
"heap_alloc_mb": float64(memStats.HeapAlloc) / 1024 / 1024,
"heap_inuse_mb": float64(memStats.HeapInuse) / 1024 / 1024,
"heap_objects": memStats.HeapObjects,
"goroutines": runtime.NumGoroutine(),
"gc_cycles": memStats.NumGC,
"gc_pause_total_ms": float64(memStats.PauseTotalNs) / 1e6,
}
// Add database health if available
if s.store != nil {
dbHealth := s.store.HealthCheck(r.Context())
response["database"] = map[string]any{
"status": dbHealth.Status,
"query_latency_ms": float64(dbHealth.QueryLatency) / 1e6,
"pool": dbHealth.PoolStats,
"warning": dbHealth.Warning,
}
}
// Add embedding model info
if s.embedSvc != nil {
response["embeddingModel"] = map[string]any{
"name": s.embedSvc.Name(),
"version": s.embedSvc.Version(),
"dimensions": s.embedSvc.Dimensions(),
}
}
// Add vector cache stats
if s.vectorClient != nil {
if count, err := s.vectorClient.Count(r.Context()); err == nil {
response["vectorCount"] = count
}
cacheSize, cacheMax := s.vectorClient.CacheStats()
response["vectorCache"] = map[string]any{
"size": cacheSize,
"max_size": cacheMax,
}
}
// Include project-specific observation count if project is specified
if project != "" {
count, err := s.getCachedObservationCount(r.Context(), project)
if err == nil {
response["projectObservations"] = count
response["project"] = project
}
}
// Add rate limiter stats
if s.rateLimiter != nil {
response["rateLimiter"] = s.rateLimiter.Stats()
}
// Add circuit breaker metrics
if s.processor != nil {
response["circuitBreaker"] = s.processor.CircuitBreakerMetrics()
}
writeJSON(w, response)
}
// handleGetRetrievalStats returns detailed retrieval statistics.
func (s *Service) handleGetRetrievalStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
stats := s.GetRetrievalStats(project)
writeJSON(w, stats)
}
// handleGetRecentQueries returns recent search queries for analytics.
func (s *Service) handleGetRecentQueries(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
limit := gorm.ParseLimitParam(r, 20)
queries := s.getRecentSearchQueries(project, limit)
writeJSON(w, map[string]any{
"queries": queries,
"count": len(queries),
"project": project,
})
}
// handleGetSearchAnalytics returns comprehensive search analytics and statistics.
func (s *Service) handleGetSearchAnalytics(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
// Get all recent queries for analysis
queries := s.getRecentSearchQueries(project, maxRecentQueries)
// Calculate analytics
totalQueries := len(queries)
vectorSearches := 0
totalResults := 0
zeroResultQueries := 0
queryTypes := make(map[string]int)
topKeywords := make(map[string]int)
for _, q := range queries {
if q.UsedVector {
vectorSearches++
}
totalResults += q.Results
if q.Results == 0 {
zeroResultQueries++
}
queryTypes[q.Type]++
// Extract keywords (simple word tokenization using iterator)
for word := range strings.FieldsSeq(strings.ToLower(q.Query)) {
if len(word) > 3 { // Skip short words
topKeywords[word]++
}
}
}
// Sort keywords by frequency
type keywordCount struct {
Keyword string `json:"keyword"`
Count int `json:"count"`
}
sortedKeywords := make([]keywordCount, 0, len(topKeywords))
for kw, count := range topKeywords {
sortedKeywords = append(sortedKeywords, keywordCount{Keyword: kw, Count: count})
}
sort.Slice(sortedKeywords, func(i, j int) bool {
return sortedKeywords[i].Count > sortedKeywords[j].Count
})
if len(sortedKeywords) > 10 {
sortedKeywords = sortedKeywords[:10]
}
// Calculate averages
avgResults := float64(0)
vectorSearchRate := float64(0)
zeroResultRate := float64(0)
if totalQueries > 0 {
avgResults = float64(totalResults) / float64(totalQueries)
vectorSearchRate = float64(vectorSearches) / float64(totalQueries) * 100
zeroResultRate = float64(zeroResultQueries) / float64(totalQueries) * 100
}
writeJSON(w, map[string]any{
"total_queries": totalQueries,
"vector_search_rate": vectorSearchRate,
"avg_results": avgResults,
"zero_result_rate": zeroResultRate,
"query_types": queryTypes,
"top_keywords": sortedKeywords,
"project": project,
})
}
// handleVectorHealth returns comprehensive health information about the vector database.
func (s *Service) handleVectorHealth(w http.ResponseWriter, r *http.Request) {
if s.vectorClient == nil {
http.Error(w, "vector client not initialized", http.StatusServiceUnavailable)
return
}
stats, err := s.vectorClient.GetHealthStats(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Add additional computed metrics
healthScore := 100.0
var warnings []string
// Penalize for stale vectors
if stats.TotalVectors > 0 {
staleRatio := float64(stats.StaleVectors) / float64(stats.TotalVectors)
if staleRatio > 0 {
healthScore -= staleRatio * 50 // Up to 50 points off for stale vectors
warnings = append(warnings, formatWarning("%.1f%% vectors need rebuild", staleRatio*100))
}
}
// Check cache effectiveness
cacheHitRate := stats.EmbeddingCache.HitRate()
if cacheHitRate < 20 && (stats.EmbeddingCache.EmbeddingHits+stats.EmbeddingCache.EmbeddingMisses) > 100 {
healthScore -= 10
warnings = append(warnings, formatWarning("Low cache hit rate: %.1f%%", cacheHitRate))
}
// Penalize if rebuild is needed
if stats.NeedsRebuild {
healthScore -= 20
warnings = append(warnings, "Vector rebuild recommended: "+stats.RebuildReason)
}
if healthScore < 0 {
healthScore = 0
}
status := "healthy"
if healthScore < 50 {
status = "unhealthy"
} else if healthScore < 80 {
status = "degraded"
}
writeJSON(w, map[string]any{
"status": status,
"health_score": healthScore,
"warnings": warnings,
"stats": stats,
"cache_hit_rate": cacheHitRate,
})
}
// UpdateObservationRequest is the request body for updating an observation.
type UpdateObservationRequest struct {
Title *string `json:"title,omitempty"`
Subtitle *string `json:"subtitle,omitempty"`
Narrative *string `json:"narrative,omitempty"`
Facts []string `json:"facts,omitempty"`
Concepts []string `json:"concepts,omitempty"`
FilesRead []string `json:"files_read,omitempty"`
FilesModified []string `json:"files_modified,omitempty"`
Scope *string `json:"scope,omitempty"`
}
// handleUpdateObservation updates an existing observation.
// PUT /api/observations/{id}
func (s *Service) handleUpdateObservation(w http.ResponseWriter, r *http.Request) {
// Parse observation ID from URL
id, ok := parseIDParam(w, r.PathValue("id"), "observation")
if !ok {
return
}
// Parse request body
var req UpdateObservationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
// Build update struct - only include fields that were provided
update := &gorm.ObservationUpdate{}
if req.Title != nil {
update.Title = req.Title
}
if req.Subtitle != nil {
update.Subtitle = req.Subtitle
}
if req.Narrative != nil {
update.Narrative = req.Narrative
}
if req.Facts != nil {
update.Facts = &req.Facts
}
if req.Concepts != nil {
update.Concepts = &req.Concepts
}
if req.FilesRead != nil {
update.FilesRead = &req.FilesRead
}
if req.FilesModified != nil {
update.FilesModified = &req.FilesModified
}
if req.Scope != nil {
// Validate scope
if *req.Scope != "project" && *req.Scope != "global" {
http.Error(w, "scope must be 'project' or 'global'", http.StatusBadRequest)
return
}
update.Scope = req.Scope
}
// Update the observation
updatedObs, err := s.observationStore.UpdateObservation(r.Context(), id, update)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
http.Error(w, "failed to update observation: "+err.Error(), http.StatusInternalServerError)
return
}
// Trigger vector resync for the updated observation
if s.vectorSync != nil {
s.asyncVectorSync(func() {
if err := s.vectorSync.SyncObservation(s.ctx, updatedObs); err != nil {
log.Warn().Err(err).Int64("id", id).Msg("Failed to resync observation vectors after update")
}
})
}
// Broadcast update event
s.sseBroadcaster.Broadcast(map[string]any{
"type": "observation_updated",
"id": id,
})
writeJSON(w, map[string]any{
"observation": updatedObs,
"message": "observation updated successfully",
})
}
// handleGetObservationByID returns a single observation by ID.
// GET /api/observations/{id}
func (s *Service) handleGetObservationByID(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r.PathValue("id"), "observation")
if !ok {
return
}
obs, err := s.observationStore.GetObservationByID(r.Context(), id)
if err != nil {
http.Error(w, "failed to get observation: "+err.Error(), http.StatusInternalServerError)
return
}
if obs == nil {
http.Error(w, "observation not found", http.StatusNotFound)
return
}
writeJSON(w, obs)
}
+680
View File
@@ -0,0 +1,680 @@
// Package worker provides import, export, and archive HTTP handlers.
package worker
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/lukaszraczylo/claude-mnemonic/pkg/similarity"
"github.com/rs/zerolog/log"
)
// BulkImportRequest is the request body for bulk observation import.
type BulkImportRequest struct {
Project string `json:"project"`
Observations []BulkObservationInput `json:"observations"`
}
// BulkObservationInput represents a single observation in bulk import.
type BulkObservationInput struct {
Type string `json:"type"` // bugfix, feature, refactor, etc.
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
Facts []string `json:"facts,omitempty"`
Narrative string `json:"narrative,omitempty"`
Concepts []string `json:"concepts,omitempty"`
FilesRead []string `json:"files_read,omitempty"`
FilesModified []string `json:"files_modified,omitempty"`
Scope string `json:"scope,omitempty"` // project or global
}
// BulkImportResponse contains the result of a bulk import operation.
type BulkImportResponse struct {
Imported int `json:"imported"`
Failed int `json:"failed"`
SkippedDuplicates int `json:"skipped_duplicates,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// handleBulkImport handles bulk import of observations.
// This is useful for migrating data or importing observations from external sources.
func (s *Service) handleBulkImport(w http.ResponseWriter, r *http.Request) {
// Rate limit bulk operations to prevent DoS
if s.bulkOpLimiter != nil && !s.bulkOpLimiter.CanExecute() {
remaining := s.bulkOpLimiter.CooldownRemaining()
http.Error(w, fmt.Sprintf("bulk import rate limited, retry in %d seconds", remaining), http.StatusTooManyRequests)
return
}
var req BulkImportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if req.Project == "" {
http.Error(w, "project is required", http.StatusBadRequest)
return
}
// Validate project name to prevent path traversal
if err := ValidateProjectName(req.Project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.Observations) == 0 {
http.Error(w, "at least one observation is required", http.StatusBadRequest)
return
}
// Limit batch size to prevent overwhelming the system
maxBatchSize := 100
if len(req.Observations) > maxBatchSize {
http.Error(w, fmt.Sprintf("batch size exceeds maximum of %d", maxBatchSize), http.StatusBadRequest)
return
}
// Create a synthetic session for bulk import
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), fmt.Sprintf("bulk-import-%d", time.Now().UnixMilli()), req.Project, "bulk import")
if err != nil {
http.Error(w, "failed to create import session: "+err.Error(), http.StatusInternalServerError)
return
}
var imported, failed, skippedDupes int
var errors []string
// Track imported observations for deduplication within the batch
importedObs := make([]*models.Observation, 0, len(req.Observations))
// Deduplication threshold - observations more similar than this are considered duplicates
const dedupThreshold = 0.7
for i, obsInput := range req.Observations {
// Validate observation type using O(1) map lookup
if !IsValidObservationType(obsInput.Type) {
failed++
errors = append(errors, fmt.Sprintf("observation %d: invalid type '%s'", i, obsInput.Type))
continue
}
// Build parsed observation
parsedObs := &models.ParsedObservation{
Type: models.ObservationType(obsInput.Type),
Title: obsInput.Title,
Subtitle: obsInput.Subtitle,
Facts: obsInput.Facts,
Narrative: obsInput.Narrative,
Concepts: obsInput.Concepts,
FilesRead: obsInput.FilesRead,
FilesModified: obsInput.FilesModified,
Scope: models.ObservationScope(obsInput.Scope),
}
// Convert to temporary observation for similarity check
tempObs := &models.Observation{
Title: sql.NullString{String: parsedObs.Title, Valid: parsedObs.Title != ""},
Subtitle: sql.NullString{String: parsedObs.Subtitle, Valid: parsedObs.Subtitle != ""},
Narrative: sql.NullString{String: parsedObs.Narrative, Valid: parsedObs.Narrative != ""},
}
// Check for duplicates within this import batch
if similarity.IsSimilarToAny(tempObs, importedObs, dedupThreshold) {
skippedDupes++
continue
}
// Store observation
obsID, _, err := s.observationStore.StoreObservation(
r.Context(),
fmt.Sprintf("bulk-import-%d", sessionID),
req.Project,
parsedObs,
0, // prompt number
0, // discovery tokens
)
if err != nil {
failed++
errors = append(errors, fmt.Sprintf("observation %d: %v", i, err))
continue
}
// Sync to vector DB asynchronously with rate limiting
if s.vectorSync != nil {
s.asyncVectorSync(func() {
// Use service context as parent to respect shutdown signals
ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)
defer cancel()
obs, err := s.observationStore.GetObservationByID(ctx, obsID)
if err == nil && obs != nil {
if syncErr := s.vectorSync.SyncObservation(ctx, obs); syncErr != nil {
if s.ctx.Err() == nil { // Don't log during shutdown
log.Debug().Err(syncErr).Int64("id", obsID).Msg("Failed to sync observation during bulk import")
}
}
}
})
}
// Track for deduplication of subsequent observations in this batch
importedObs = append(importedObs, tempObs)
imported++
}
log.Info().
Str("project", req.Project).
Int("imported", imported).
Int("failed", failed).
Int("skipped_duplicates", skippedDupes).
Msg("Bulk import completed")
// Invalidate observation count cache after import
if imported > 0 {
if req.Project != "" {
s.invalidateObsCountCache(req.Project)
} else {
s.invalidateAllObsCountCache()
}
}
// Broadcast observation event for dashboard refresh
s.sseBroadcaster.Broadcast(map[string]any{
"type": "observation",
"action": "bulk_import",
"project": req.Project,
"count": imported,
})
writeJSON(w, BulkImportResponse{
Imported: imported,
Failed: failed,
SkippedDuplicates: skippedDupes,
Errors: errors,
})
}
// ArchiveRequest is the request body for archiving observations.
type ArchiveRequest struct {
IDs []int64 `json:"ids,omitempty"` // Specific IDs to archive
Project string `json:"project,omitempty"` // Archive all in project older than max_age_days
MaxAgeDays int `json:"max_age_days,omitempty"` // Only used with project
Reason string `json:"reason,omitempty"` // Optional reason for archival
}
// handleArchiveObservations archives observations by ID or by age.
// Supports batch archival with error tracking per observation.
func (s *Service) handleArchiveObservations(w http.ResponseWriter, r *http.Request) {
var req ArchiveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
var archivedIDs []int64
var failedIDs []int64
var errors []string
var err error
if len(req.IDs) > 0 {
// Archive specific observations with parallel processing for large batches
if len(req.IDs) > 5 {
// Use parallel archival for batches larger than 5
type archiveResult struct {
id int64
err error
}
results := make(chan archiveResult, len(req.IDs))
// Limit concurrency to avoid overwhelming the database
sem := make(chan struct{}, 5)
var wg sync.WaitGroup
for _, id := range req.IDs {
wg.Add(1)
go func(obsID int64) {
defer wg.Done()
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
archErr := s.observationStore.ArchiveObservation(r.Context(), obsID, req.Reason)
results <- archiveResult{id: obsID, err: archErr}
}(id)
}
// Close results channel when all goroutines complete
go func() {
wg.Wait()
close(results)
}()
// Collect results
for res := range results {
if res.err != nil {
log.Warn().Err(res.err).Int64("id", res.id).Msg("Failed to archive observation")
failedIDs = append(failedIDs, res.id)
errors = append(errors, fmt.Sprintf("id %d: %v", res.id, res.err))
} else {
archivedIDs = append(archivedIDs, res.id)
}
}
} else {
// Sequential for small batches
for _, id := range req.IDs {
if archErr := s.observationStore.ArchiveObservation(r.Context(), id, req.Reason); archErr != nil {
log.Warn().Err(archErr).Int64("id", id).Msg("Failed to archive observation")
failedIDs = append(failedIDs, id)
errors = append(errors, fmt.Sprintf("id %d: %v", id, archErr))
} else {
archivedIDs = append(archivedIDs, id)
}
}
}
} else if req.Project != "" || req.MaxAgeDays > 0 {
// Archive by age
archivedIDs, err = s.observationStore.ArchiveOldObservations(r.Context(), req.Project, req.MaxAgeDays, req.Reason)
if err != nil {
http.Error(w, "failed to archive: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "either 'ids' or 'project'/'max_age_days' is required", http.StatusBadRequest)
return
}
log.Info().
Str("project", req.Project).
Int("archived", len(archivedIDs)).
Int("failed", len(failedIDs)).
Msg("Observations archived")
// Invalidate cache if any observations were archived
if len(archivedIDs) > 0 {
if req.Project != "" {
s.invalidateObsCountCache(req.Project)
} else {
s.invalidateAllObsCountCache()
}
}
response := map[string]any{
"archived_count": len(archivedIDs),
"archived_ids": archivedIDs,
}
if len(failedIDs) > 0 {
response["failed_count"] = len(failedIDs)
response["failed_ids"] = failedIDs
response["errors"] = errors
}
writeJSON(w, response)
}
// handleUnarchiveObservation restores an archived observation.
func (s *Service) handleUnarchiveObservation(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid observation id", http.StatusBadRequest)
return
}
if err := s.observationStore.UnarchiveObservation(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Invalidate all caches since we don't know the project
s.invalidateAllObsCountCache()
writeJSON(w, map[string]any{
"success": true,
"id": id,
})
}
// handleGetArchivedObservations returns archived observations.
func (s *Service) handleGetArchivedObservations(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
limit := gorm.ParseLimitParam(r, DefaultObservationsLimit)
observations, err := s.observationStore.GetArchivedObservations(r.Context(), project, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if observations == nil {
observations = []*models.Observation{}
}
writeJSON(w, observations)
}
// handleGetArchivalStats returns archival statistics.
func (s *Service) handleGetArchivalStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
stats, err := s.observationStore.GetArchivalStats(r.Context(), project)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, stats)
}
// handleExportObservations exports observations in JSON or CSV format.
// Supports query parameters: project, format (json/csv), scope, type, limit.
func (s *Service) handleExportObservations(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
format := r.URL.Query().Get("format")
if format == "" {
format = "json"
}
scope := r.URL.Query().Get("scope") // project, global, or empty for all
obsType := r.URL.Query().Get("type") // bugfix, feature, etc.
limit := gorm.ParseLimitParamWithMax(r, 1000, 5000) // Higher limit for exports, capped at 5000
// Validate format
if format != "json" && format != "csv" {
http.Error(w, "format must be 'json' or 'csv'", http.StatusBadRequest)
return
}
// Get observations with filters
ctx := r.Context()
var observations []*models.Observation
var err error
if project != "" {
observations, _, err = s.observationStore.GetObservationsByProjectStrictPaginated(ctx, project, limit, 0)
} else {
observations, _, err = s.observationStore.GetAllRecentObservationsPaginated(ctx, limit, 0)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Apply additional filters
if scope != "" || obsType != "" {
filtered := make([]*models.Observation, 0, len(observations))
for _, obs := range observations {
if scope != "" && string(obs.Scope) != scope {
continue
}
if obsType != "" && string(obs.Type) != obsType {
continue
}
filtered = append(filtered, obs)
}
observations = filtered
}
// Generate filename
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("observations-%s.%s", timestamp, format)
if project != "" {
// Sanitize project name for filename
sanitized := strings.ReplaceAll(project, "/", "_")
sanitized = strings.ReplaceAll(sanitized, "\\", "_")
if len(sanitized) > 50 {
sanitized = sanitized[:50]
}
filename = fmt.Sprintf("observations-%s-%s.%s", sanitized, timestamp, format)
}
switch format {
case "csv":
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
s.writeObservationsCSV(w, observations)
default: // json
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
writeJSON(w, map[string]any{
"exported_at": time.Now().Format(time.RFC3339),
"project": project,
"count": len(observations),
"observations": observations,
})
}
}
// writeObservationsCSV writes observations in CSV format.
// Uses fmt.Fprintf directly to avoid intermediate string allocations.
func (s *Service) writeObservationsCSV(w http.ResponseWriter, observations []*models.Observation) {
// Write CSV header
_, _ = io.WriteString(w, "id,type,scope,project,title,subtitle,narrative,concepts,facts,created_at,importance_score\n")
for _, obs := range observations {
// Write directly to avoid string allocation per row
_, _ = fmt.Fprintf(w, "%d,%s,%s,%s,%s,%s,%s,%s,%s,%s,%.2f\n",
obs.ID,
obs.Type,
obs.Scope,
escapeCsvField(obs.Project),
escapeCsvField(obs.Title.String),
escapeCsvField(obs.Subtitle.String),
escapeCsvField(obs.Narrative.String),
escapeCsvField(strings.Join(obs.Concepts, ";")),
escapeCsvField(strings.Join(obs.Facts, ";")),
obs.CreatedAt,
obs.ImportanceScore,
)
}
}
// escapeCsvField escapes a field for CSV output.
func escapeCsvField(s string) string {
// If field contains comma, quote, or newline, wrap in quotes and escape quotes
if strings.ContainsAny(s, ",\"\n\r") {
s = strings.ReplaceAll(s, "\"", "\"\"")
return "\"" + s + "\""
}
return s
}
// BulkStatusRequest represents a request to update status for multiple observations.
type BulkStatusRequest struct {
IDs []int64 `json:"ids"`
Action string `json:"action"` // "supersede", "archive", "set_feedback"
Reason string `json:"reason,omitempty"`
Feedback int `json:"feedback,omitempty"` // -1, 0, 1 for set_feedback action
}
// handleBulkStatusUpdate updates status for multiple observations in one request.
func (s *Service) handleBulkStatusUpdate(w http.ResponseWriter, r *http.Request) {
var req BulkStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
http.Error(w, "ids is required", http.StatusBadRequest)
return
}
if len(req.IDs) > 500 {
http.Error(w, "maximum 500 ids per request", http.StatusBadRequest)
return
}
ctx := r.Context()
var updated, failed int
var errors []string
switch req.Action {
case "supersede":
for _, id := range req.IDs {
if err := s.observationStore.MarkAsSuperseded(ctx, id); err != nil {
failed++
errors = append(errors, fmt.Sprintf("id %d: %v", id, err))
} else {
updated++
}
}
case "archive":
for _, id := range req.IDs {
if err := s.observationStore.ArchiveObservation(ctx, id, req.Reason); err != nil {
failed++
errors = append(errors, fmt.Sprintf("id %d: %v", id, err))
} else {
updated++
}
}
case "set_feedback":
if req.Feedback < -1 || req.Feedback > 1 {
http.Error(w, "feedback must be -1, 0, or 1", http.StatusBadRequest)
return
}
for _, id := range req.IDs {
if err := s.observationStore.UpdateObservationFeedback(ctx, id, req.Feedback); err != nil {
failed++
errors = append(errors, fmt.Sprintf("id %d: %v", id, err))
} else {
updated++
}
}
default:
http.Error(w, "action must be 'supersede', 'archive', or 'set_feedback'", http.StatusBadRequest)
return
}
// Invalidate cache for archive action (affects observation counts)
if req.Action == "archive" && updated > 0 {
// No project info available, invalidate all caches
s.invalidateAllObsCountCache()
}
response := map[string]any{
"action": req.Action,
"updated": updated,
"failed": failed,
}
if len(errors) > 0 {
response["errors"] = errors
}
writeJSON(w, response)
}
// handleFindDuplicates finds potential duplicate observations using similarity clustering.
// Returns groups of similar observations that may be candidates for merging or archival.
func (s *Service) handleFindDuplicates(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
thresholdStr := r.URL.Query().Get("threshold")
limit := gorm.ParseLimitParam(r, 100)
// Parse threshold (default 0.6 = 60% similarity)
threshold := 0.6
if thresholdStr != "" {
if t, err := strconv.ParseFloat(thresholdStr, 64); err == nil && t > 0 && t < 1 {
threshold = t
}
}
// Get recent observations
ctx := r.Context()
var observations []*models.Observation
var err error
if project != "" {
observations, _, err = s.observationStore.GetObservationsByProjectStrictPaginated(ctx, project, limit, 0)
} else {
observations, _, err = s.observationStore.GetAllRecentObservationsPaginated(ctx, limit, 0)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(observations) < 2 {
writeJSON(w, map[string]any{
"duplicate_groups": []any{},
"total_checked": len(observations),
"threshold": threshold,
})
return
}
// Find duplicates using similarity comparison
type duplicateGroup struct {
Observations []map[string]any `json:"observations"`
Similarity float64 `json:"similarity"`
}
groups := []duplicateGroup{}
processed := make(map[int64]bool)
for i, obs1 := range observations {
if processed[obs1.ID] {
continue
}
terms1 := similarity.ExtractObservationTerms(obs1)
if len(terms1) == 0 {
continue
}
group := duplicateGroup{
Observations: []map[string]any{obs1.ToMap()},
Similarity: 1.0,
}
for j := i + 1; j < len(observations); j++ {
obs2 := observations[j]
if processed[obs2.ID] {
continue
}
terms2 := similarity.ExtractObservationTerms(obs2)
sim := similarity.JaccardSimilarity(terms1, terms2)
if sim >= threshold {
obsMap := obs2.ToMap()
obsMap["similarity_to_first"] = sim
group.Observations = append(group.Observations, obsMap)
group.Similarity = min(group.Similarity, sim)
processed[obs2.ID] = true
}
}
if len(group.Observations) > 1 {
processed[obs1.ID] = true
groups = append(groups, group)
}
}
// Sort groups by size (largest first)
sort.Slice(groups, func(i, j int) bool {
return len(groups[i].Observations) > len(groups[j].Observations)
})
writeJSON(w, map[string]any{
"duplicate_groups": groups,
"total_checked": len(observations),
"groups_found": len(groups),
"threshold": threshold,
"project": project,
})
}
+9 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models" "github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
) )
// FeedbackRequest represents a user feedback submission. // FeedbackRequest represents a user feedback submission.
@@ -311,8 +312,7 @@ func (s *Service) handleTriggerRecalculation(w http.ResponseWriter, r *http.Requ
// Run recalculation in background // Run recalculation in background
go func() { go func() {
if err := recalculator.RecalculateNow(r.Context()); err != nil { if err := recalculator.RecalculateNow(r.Context()); err != nil {
// Log error but don't block response log.Warn().Err(err).Msg("Background score recalculation failed")
_ = err // Explicitly ignore - background operation
} }
}() }()
@@ -345,14 +345,18 @@ func (s *Service) incrementRetrievalCounts(ids []int64) {
} }
// Increment in background to not block response // Increment in background to not block response
// Use service context to respect shutdown signals
s.wg.Add(1)
go func() { go func() {
// Create a new context with timeout for the background operation defer s.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(s.ctx, 3*time.Second)
defer cancel() defer cancel()
if err := store.IncrementRetrievalCount(ctx, ids); err != nil { if err := store.IncrementRetrievalCount(ctx, ids); err != nil {
// Log but don't fail - this is a background operation // Log but don't fail - this is a background operation
_ = err // Explicitly ignore - background operation if s.ctx.Err() == nil { // Don't log during shutdown
log.Debug().Err(err).Msg("Failed to increment retrieval counts")
}
} }
}() }()
} }
+354
View File
@@ -0,0 +1,354 @@
// Package worker provides session-related HTTP handlers.
package worker
import (
"context"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/lukaszraczylo/claude-mnemonic/internal/privacy"
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/session"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// SessionInitRequest is the request body for session initialization.
type SessionInitRequest struct {
ClaudeSessionID string `json:"claudeSessionId"`
Project string `json:"project"`
Prompt string `json:"prompt"`
MatchedObservations int `json:"matchedObservations"`
}
// SessionInitResponse is the response for session initialization.
type SessionInitResponse struct {
SessionDBID int64 `json:"sessionDbId"`
PromptNumber int `json:"promptNumber"`
Skipped bool `json:"skipped,omitempty"`
Reason string `json:"reason,omitempty"`
}
// DuplicatePromptWindowSeconds is the time window for detecting duplicate prompt submissions.
// If the same prompt text is seen within this window, it's considered a duplicate hook invocation.
const DuplicatePromptWindowSeconds = 10
// handleSessionInit handles session initialization from user-prompt hook.
// This handler is idempotent - duplicate requests within a short time window
// return the existing prompt data without creating duplicates.
func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
var req SessionInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Privacy check
if privacy.IsEntirelyPrivate(req.Prompt) {
// Create session but skip processing
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, "")
promptNum, _ := s.sessionStore.IncrementPromptCounter(r.Context(), sessionID)
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: promptNum,
Skipped: true,
Reason: "private",
})
return
}
// Clean prompt
cleanedPrompt := privacy.Clean(req.Prompt)
// DUPLICATE DETECTION: Check if this exact prompt was already saved recently.
// This prevents the bug where the hook fires multiple times for the same user action,
// creating many duplicate prompts with incrementing numbers.
if existingID, existingNum, found := s.promptStore.FindRecentPromptByText(r.Context(), req.ClaudeSessionID, cleanedPrompt, DuplicatePromptWindowSeconds); found {
// Get or create session (idempotent)
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
log.Debug().
Int64("sessionId", sessionID).
Int("promptNumber", existingNum).
Int64("promptId", existingID).
Msg("Duplicate prompt detected - returning existing")
// Return existing prompt data without incrementing or saving again
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: existingNum,
})
return
}
// Create session (idempotent)
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Increment prompt counter
promptNum, err := s.sessionStore.IncrementPromptCounter(r.Context(), sessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Save user prompt with matched observation count
promptID, err := s.promptStore.SaveUserPromptWithMatches(r.Context(), req.ClaudeSessionID, promptNum, cleanedPrompt, req.MatchedObservations)
if err != nil {
log.Warn().Err(err).Msg("Failed to save user prompt")
// Non-fatal: continue with session initialization
} else if s.vectorSync != nil {
// Sync to vector DB asynchronously (non-blocking)
now := time.Now()
promptWithSession := &models.UserPromptWithSession{
UserPrompt: models.UserPrompt{
ID: promptID,
ClaudeSessionID: req.ClaudeSessionID,
PromptNumber: promptNum,
PromptText: cleanedPrompt,
MatchedObservations: req.MatchedObservations,
CreatedAt: now.Format(time.RFC3339),
CreatedAtEpoch: now.UnixMilli(),
},
Project: req.Project,
SDKSessionID: req.ClaudeSessionID,
}
s.asyncVectorSync(func() {
// Use service context as parent to respect shutdown signals
ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)
defer cancel()
if err := s.vectorSync.SyncUserPrompt(ctx, promptWithSession); err != nil {
if s.ctx.Err() == nil { // Don't log during shutdown
log.Warn().Err(err).Int64("id", promptID).Msg("Failed to sync user prompt to sqlite-vec")
}
}
})
}
log.Info().
Int64("sessionId", sessionID).
Int("promptNumber", promptNum).
Str("project", req.Project).
Msg("Session initialized")
// Broadcast prompt event for dashboard refresh
s.sseBroadcaster.Broadcast(map[string]any{
"type": "prompt",
"action": "created",
"project": req.Project,
})
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: promptNum,
})
}
// SessionStartRequest is the request body for starting SDK agent.
type SessionStartRequest struct {
UserPrompt string `json:"userPrompt"`
PromptNumber int `json:"promptNumber"`
}
// handleSessionStart handles SDK agent session start.
func (s *Service) handleSessionStart(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid session id", http.StatusBadRequest)
return
}
var req SessionStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Initialize session in manager
sess, err := s.sessionManager.InitializeSession(r.Context(), id, req.UserPrompt, req.PromptNumber)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
// Session is now registered. Observations will be processed
// asynchronously by the background queue processor (processQueue in service.go).
log.Info().
Int64("sessionId", id).
Int("promptNumber", req.PromptNumber).
Msg("SDK agent session initialized")
s.broadcastProcessingStatus()
w.WriteHeader(http.StatusOK)
}
// ObservationRequest is the request body for posting observations.
type ObservationRequest struct {
ClaudeSessionID string `json:"claudeSessionId"`
Project string `json:"project"`
ToolName string `json:"tool_name"`
ToolInput any `json:"tool_input"`
ToolResponse any `json:"tool_response"`
CWD string `json:"cwd"`
}
// handleObservation handles observation posting from post-tool-use hook.
func (s *Service) handleObservation(w http.ResponseWriter, r *http.Request) {
var req ObservationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Find session
sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sess == nil {
// Create session on-the-fly with project from request
id, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sess, _ = s.sessionStore.GetSessionByID(r.Context(), id)
}
// Queue observation
if err := s.sessionManager.QueueObservation(r.Context(), sess.ID, session.ObservationData{
ToolName: req.ToolName,
ToolInput: req.ToolInput,
ToolResponse: req.ToolResponse,
CWD: req.CWD,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.broadcastProcessingStatus()
w.WriteHeader(http.StatusOK)
}
// SubagentCompleteRequest is the request body for subagent completion.
type SubagentCompleteRequest struct {
ClaudeSessionID string `json:"claudeSessionId"`
Project string `json:"project"`
}
// handleSubagentComplete handles subagent/Task completion notifications.
// This triggers immediate processing of any queued observations from the subagent.
func (s *Service) handleSubagentComplete(w http.ResponseWriter, r *http.Request) {
var req SubagentCompleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Find session
sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID)
if err != nil || sess == nil {
// Session not found - subagent may have been in a different context
log.Debug().
Str("claudeSessionId", req.ClaudeSessionID).
Msg("Subagent complete - no active session found")
w.WriteHeader(http.StatusOK)
return
}
// Trigger immediate processing of queued observations
messages := s.sessionManager.DrainMessages(sess.ID)
if len(messages) > 0 && s.processor != nil {
log.Info().
Int64("sessionId", sess.ID).
Int("messages", len(messages)).
Msg("Processing queued observations from subagent")
for _, msg := range messages {
if msg.Type == session.MessageTypeObservation && msg.Observation != nil {
err := s.processor.ProcessObservation(
r.Context(),
sess.SDKSessionID.String,
sess.Project,
msg.Observation.ToolName,
msg.Observation.ToolInput,
msg.Observation.ToolResponse,
msg.Observation.PromptNumber,
msg.Observation.CWD,
)
if err != nil {
log.Error().Err(err).
Str("tool", msg.Observation.ToolName).
Msg("Failed to process subagent observation")
}
}
}
}
s.broadcastProcessingStatus()
w.WriteHeader(http.StatusOK)
}
// handleGetSessionByClaudeID looks up a session by Claude session ID.
func (s *Service) handleGetSessionByClaudeID(w http.ResponseWriter, r *http.Request) {
claudeSessionID := r.URL.Query().Get("claudeSessionId")
if claudeSessionID == "" {
http.Error(w, "claudeSessionId required", http.StatusBadRequest)
return
}
session, err := s.sessionStore.FindAnySDKSession(r.Context(), claudeSessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if session == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
writeJSON(w, session)
}
// SummarizeRequest is the request body for summarize requests.
type SummarizeRequest struct {
LastUserMessage string `json:"lastUserMessage"`
LastAssistantMessage string `json:"lastAssistantMessage"`
}
// handleSummarize handles summarize requests from stop hook.
func (s *Service) handleSummarize(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid session id", http.StatusBadRequest)
return
}
var req SummarizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Queue summarize request
if err := s.sessionManager.QueueSummarize(r.Context(), id, req.LastUserMessage, req.LastAssistantMessage); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.broadcastProcessingStatus()
w.WriteHeader(http.StatusOK)
}
+243
View File
@@ -0,0 +1,243 @@
// Package worker provides update and restart HTTP handlers.
package worker
import (
"fmt"
"net/http"
"time"
"github.com/rs/zerolog/log"
)
// handleUpdateCheck checks for available updates.
func (s *Service) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
info, err := s.updater.CheckForUpdate(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, info)
}
// handleUpdateApply downloads and applies an available update.
func (s *Service) handleUpdateApply(w http.ResponseWriter, r *http.Request) {
// First check for update
info, err := s.updater.CheckForUpdate(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !info.Available {
writeJSON(w, map[string]any{
"success": false,
"message": "No update available",
})
return
}
// Apply update in background with tracking for graceful shutdown
s.wg.Go(func() {
if err := s.updater.ApplyUpdate(s.ctx, info); err != nil {
log.Error().Err(err).Msg("Update failed")
}
})
writeJSON(w, map[string]any{
"success": true,
"message": "Update started",
"version": info.LatestVersion,
})
}
// handleUpdateStatus returns the current update status.
func (s *Service) handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
status := s.updater.GetStatus()
writeJSON(w, status)
}
// ComponentHealth represents the health status of a single component.
type ComponentHealth struct {
Name string `json:"name"`
Status string `json:"status"` // "healthy", "degraded", "unhealthy"
Message string `json:"message,omitempty"`
}
// SelfCheckResponse contains the health status of all components.
type SelfCheckResponse struct {
Overall string `json:"overall"` // "healthy", "degraded", "unhealthy"
Version string `json:"version"`
Uptime string `json:"uptime"`
Components []ComponentHealth `json:"components"`
}
// handleSelfCheck returns the health status of all components.
func (s *Service) handleSelfCheck(w http.ResponseWriter, r *http.Request) {
components := []ComponentHealth{}
overall := "healthy"
// Check Worker Service
workerStatus := ComponentHealth{Name: "Worker Service", Status: "healthy"}
if !s.ready.Load() {
if err := s.GetInitError(); err != nil {
workerStatus.Status = "unhealthy"
workerStatus.Message = err.Error()
overall = "unhealthy"
} else {
workerStatus.Status = "degraded"
workerStatus.Message = "Initializing"
if overall == "healthy" {
overall = "degraded"
}
}
}
components = append(components, workerStatus)
// Check SQLite Database
dbStatus := ComponentHealth{Name: "SQLite Database", Status: "healthy"}
if s.store == nil {
dbStatus.Status = "unhealthy"
dbStatus.Message = "Not initialized"
overall = "unhealthy"
} else if err := s.store.Ping(); err != nil {
dbStatus.Status = "unhealthy"
dbStatus.Message = err.Error()
overall = "unhealthy"
}
components = append(components, dbStatus)
// Check Vector DB (sqlite-vec)
vectorStatus := ComponentHealth{Name: "Vector DB", Status: "healthy"}
if s.vectorClient == nil {
vectorStatus.Status = "degraded"
vectorStatus.Message = "Not configured"
if overall == "healthy" {
overall = "degraded"
}
} else if !s.vectorClient.IsConnected() {
vectorStatus.Status = "degraded"
vectorStatus.Message = "Not connected"
if overall == "healthy" {
overall = "degraded"
}
}
components = append(components, vectorStatus)
// Check SDK Processor
sdkStatus := ComponentHealth{Name: "SDK Processor", Status: "healthy"}
if s.processor == nil {
sdkStatus.Status = "degraded"
sdkStatus.Message = "Not initialized"
if overall == "healthy" {
overall = "degraded"
}
} else if !s.processor.IsAvailable() {
sdkStatus.Status = "degraded"
sdkStatus.Message = "Claude CLI not available"
if overall == "healthy" {
overall = "degraded"
}
}
components = append(components, sdkStatus)
// Check SSE Broadcaster
sseStatus := ComponentHealth{Name: "SSE Broadcaster", Status: "healthy"}
if s.sseBroadcaster == nil {
sseStatus.Status = "unhealthy"
sseStatus.Message = "Not initialized"
overall = "unhealthy"
}
components = append(components, sseStatus)
// Check Cross-Encoder Reranker
rerankerStatus := ComponentHealth{Name: "Cross-Encoder Reranker", Status: "healthy"}
if !s.config.RerankingEnabled {
rerankerStatus.Status = "degraded"
rerankerStatus.Message = "Disabled in config"
if overall == "healthy" {
overall = "degraded"
}
} else if s.reranker == nil {
rerankerStatus.Status = "degraded"
rerankerStatus.Message = "Not initialized"
if overall == "healthy" {
overall = "degraded"
}
} else {
// Verify reranker is functional using Score
_, normalizedScore, err := s.reranker.Score("test query", "test document")
if err != nil {
rerankerStatus.Status = "unhealthy"
rerankerStatus.Message = fmt.Sprintf("Score check failed: %v", err)
if overall == "healthy" {
overall = "degraded"
}
} else {
rerankerStatus.Message = fmt.Sprintf("Score check passed (%.4f)", normalizedScore)
}
}
components = append(components, rerankerStatus)
// Calculate uptime
uptime := time.Since(s.startTime).Round(time.Second).String()
writeJSON(w, SelfCheckResponse{
Overall: overall,
Version: s.version,
Uptime: uptime,
Components: components,
})
}
// handleUpdateRestart restarts the worker with the new binary (after update).
func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
status := s.updater.GetStatus()
if status.State != "done" {
http.Error(w, "no update has been applied", http.StatusBadRequest)
return
}
// Send response before restarting
writeJSON(w, map[string]any{
"success": true,
"message": "Restarting worker...",
})
// Flush the response
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Restart in background after response is sent
go func() {
if err := s.updater.Restart(); err != nil {
log.Error().Err(err).Msg("Failed to restart worker")
}
}()
}
// handleRestart restarts the worker process (general restart, not tied to update).
func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
log.Info().Msg("Manual restart requested via API")
// Send response before restarting
writeJSON(w, map[string]any{
"success": true,
"message": "Restarting worker...",
"version": s.version,
})
// Flush the response
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Restart in background after response is sent
go func() {
// Small delay to ensure response is sent
time.Sleep(100 * time.Millisecond)
if err := s.updater.Restart(); err != nil {
log.Error().Err(err).Msg("Failed to restart worker")
}
}()
}
+335
View File
@@ -0,0 +1,335 @@
// Package worker provides the main worker service for claude-mnemonic.
package worker
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// requestIDKey is the context key for request IDs.
type requestIDKey struct{}
// projectNamePattern validates project names to prevent path traversal.
var projectNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_./-]+$`)
// allowedOrigins is the whitelist of origins allowed for CORS.
// Uses exact matching to prevent bypass attacks like "evil-localhost.com".
var allowedOrigins = map[string]bool{
"http://localhost": true,
"http://localhost:3000": true,
"http://localhost:5173": true, // Vite dev server
"http://localhost:37778": true, // Dashboard UI
"http://127.0.0.1": true,
"http://127.0.0.1:3000": true,
"http://127.0.0.1:5173": true,
"http://127.0.0.1:37778": true,
}
// SecurityHeaders middleware adds essential security headers to all responses.
// These protect against common web vulnerabilities.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent clickjacking
w.Header().Set("X-Frame-Options", "DENY")
// Prevent MIME type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// Enable XSS filter
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Restrict referrer information
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy - restrict to self
w.Header().Set("Content-Security-Policy", "default-src 'self'")
// Permissions Policy - disable unnecessary features
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
// CORS: Use exact match whitelist to prevent bypass attacks
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token, Authorization, X-Request-ID")
}
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// MaxBodySize middleware limits the size of incoming request bodies.
// This prevents denial of service attacks via large payloads.
func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > maxBytes {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
// TokenAuth provides simple token-based authentication for localhost services.
// The token is generated at startup and must be provided in the X-Auth-Token header.
type TokenAuth struct {
token string
enabled bool
mu sync.RWMutex
// ExemptPaths are paths that don't require authentication (e.g., health checks)
ExemptPaths map[string]bool
}
// NewTokenAuth creates a new TokenAuth with a randomly generated token.
// If enabled is false, authentication is skipped (useful for development).
func NewTokenAuth(enabled bool) (*TokenAuth, error) {
ta := &TokenAuth{
enabled: enabled,
ExemptPaths: map[string]bool{
"/health": true,
"/api/health": true,
"/api/ready": true,
},
}
if enabled {
// Generate 32-byte random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, err
}
ta.token = hex.EncodeToString(tokenBytes)
}
return ta, nil
}
// Token returns the authentication token.
// Returns empty string if authentication is disabled.
func (ta *TokenAuth) Token() string {
ta.mu.RLock()
defer ta.mu.RUnlock()
return ta.token
}
// IsEnabled returns whether token authentication is enabled.
func (ta *TokenAuth) IsEnabled() bool {
ta.mu.RLock()
defer ta.mu.RUnlock()
return ta.enabled
}
// Middleware returns HTTP middleware that enforces token authentication.
func (ta *TokenAuth) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ta.mu.RLock()
enabled := ta.enabled
token := ta.token
exempt := ta.ExemptPaths[r.URL.Path]
ta.mu.RUnlock()
// Skip auth if disabled or path is exempt
if !enabled || exempt {
next.ServeHTTP(w, r)
return
}
// Check for token in header
providedToken := r.Header.Get("X-Auth-Token")
if providedToken == "" {
// Also check Authorization header with Bearer scheme
auth := r.Header.Get("Authorization")
if bearer, found := strings.CutPrefix(auth, "Bearer "); found {
providedToken = bearer
}
}
if providedToken != token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// ExpensiveOperationLimiter provides stricter rate limiting for expensive operations.
// It wraps the base per-client rate limiter with additional per-operation limits.
type ExpensiveOperationLimiter struct {
// Track last execution time per operation type
lastRebuild int64 // Unix timestamp
rebuildCooldown int64 // Minimum seconds between rebuilds
mu sync.Mutex
}
// NewExpensiveOperationLimiter creates a limiter for expensive operations.
func NewExpensiveOperationLimiter() *ExpensiveOperationLimiter {
return &ExpensiveOperationLimiter{
rebuildCooldown: 300, // 5 minutes between rebuilds
}
}
// CanRebuild checks if a vector rebuild operation is allowed.
// Returns false if a rebuild was triggered too recently.
func (eol *ExpensiveOperationLimiter) CanRebuild() bool {
eol.mu.Lock()
defer eol.mu.Unlock()
now := unixNow()
if now-eol.lastRebuild < eol.rebuildCooldown {
return false
}
eol.lastRebuild = now
return true
}
// unixNow returns current Unix timestamp.
// Separated for easier testing.
func unixNow() int64 {
return time.Now().Unix()
}
// RequestID middleware adds a unique request ID to each request.
// The ID is added to the context and response headers for tracing.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check for existing request ID from client
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
// Generate new request ID
idBytes := make([]byte, 8)
if _, err := rand.Read(idBytes); err == nil {
requestID = hex.EncodeToString(idBytes)
} else {
requestID = fmt.Sprintf("%d", time.Now().UnixNano())
}
}
// Add to response header
w.Header().Set("X-Request-ID", requestID)
// Add to context
ctx := context.WithValue(r.Context(), requestIDKey{}, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetRequestID retrieves the request ID from the context.
func GetRequestID(ctx context.Context) string {
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
return id
}
return ""
}
// RequireJSONContentType middleware validates that POST/PUT/PATCH requests
// have application/json Content-Type header.
func RequireJSONContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only check for methods that typically have bodies
if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" {
ct := r.Header.Get("Content-Type")
// Allow empty Content-Type for requests without body
if ct != "" && !strings.HasPrefix(ct, "application/json") {
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
return
}
}
next.ServeHTTP(w, r)
})
}
// ValidateProjectName checks if a project name is safe to use.
// Returns an error if the name contains path traversal or invalid characters.
func ValidateProjectName(project string) error {
if project == "" {
return nil // Empty is allowed (means no filter)
}
// Check for path traversal
if strings.Contains(project, "..") {
return fmt.Errorf("invalid project name: path traversal detected")
}
// Check for valid characters
if !projectNamePattern.MatchString(project) {
return fmt.Errorf("invalid project name: only alphanumeric, underscore, dash, dot, and slash allowed")
}
// Max length check
if len(project) > 500 {
return fmt.Errorf("project name too long (max 500 chars)")
}
return nil
}
// BulkOperationLimiter provides rate limiting for bulk operations.
// Prevents DoS via repeated bulk requests.
type BulkOperationLimiter struct {
lastBulkOp int64 // Unix timestamp
cooldown int64 // Minimum seconds between operations
mu sync.Mutex
}
// NewBulkOperationLimiter creates a limiter for bulk operations.
func NewBulkOperationLimiter(cooldownSeconds int64) *BulkOperationLimiter {
return &BulkOperationLimiter{
cooldown: cooldownSeconds,
}
}
// CanExecute checks if a bulk operation is allowed.
// Returns false if a bulk operation was triggered too recently.
func (bol *BulkOperationLimiter) CanExecute() bool {
bol.mu.Lock()
defer bol.mu.Unlock()
now := unixNow()
if now-bol.lastBulkOp < bol.cooldown {
return false
}
bol.lastBulkOp = now
return true
}
// TimeSinceLastOp returns seconds since the last bulk operation.
func (bol *BulkOperationLimiter) TimeSinceLastOp() int64 {
bol.mu.Lock()
defer bol.mu.Unlock()
return unixNow() - bol.lastBulkOp
}
// CooldownRemaining returns seconds remaining in the cooldown period.
// Returns 0 if no cooldown is active.
func (bol *BulkOperationLimiter) CooldownRemaining() int64 {
bol.mu.Lock()
defer bol.mu.Unlock()
remaining := bol.cooldown - (unixNow() - bol.lastBulkOp)
if remaining < 0 {
return 0
}
return remaining
}
+515
View File
@@ -0,0 +1,515 @@
package worker
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Check all security headers are set
tests := []struct {
header string
expected string
}{
{"X-Frame-Options", "DENY"},
{"X-Content-Type-Options", "nosniff"},
{"X-XSS-Protection", "1; mode=block"},
{"Referrer-Policy", "strict-origin-when-cross-origin"},
}
for _, tt := range tests {
if got := rr.Header().Get(tt.header); got != tt.expected {
t.Errorf("SecurityHeaders() %s = %q, want %q", tt.header, got, tt.expected)
}
}
}
func TestSecurityHeaders_CORS(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
name string
origin string
expectCORS bool
expectedOrigin string
}{
{
name: "localhost:37778 origin allowed",
origin: "http://localhost:37778",
expectCORS: true,
expectedOrigin: "http://localhost:37778",
},
{
name: "127.0.0.1:5173 origin allowed",
origin: "http://127.0.0.1:5173",
expectCORS: true,
expectedOrigin: "http://127.0.0.1:5173",
},
{
name: "localhost without port allowed",
origin: "http://localhost",
expectCORS: true,
expectedOrigin: "http://localhost",
},
{
name: "external origin blocked",
origin: "http://evil.com",
expectCORS: false,
},
{
name: "evil-localhost.com bypass attempt blocked",
origin: "http://evil-localhost.com",
expectCORS: false,
},
{
name: "localhost subdomain bypass attempt blocked",
origin: "http://localhost.evil.com",
expectCORS: false,
},
{
name: "unknown localhost port blocked",
origin: "http://localhost:9999",
expectCORS: false,
},
{
name: "no origin header",
origin: "",
expectCORS: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
if tt.origin != "" {
req.Header.Set("Origin", tt.origin)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
cors := rr.Header().Get("Access-Control-Allow-Origin")
if tt.expectCORS {
if cors != tt.expectedOrigin {
t.Errorf("Expected CORS origin %q, got %q", tt.expectedOrigin, cors)
}
} else {
if cors != "" {
t.Errorf("Expected no CORS header, got %q", cors)
}
}
})
}
}
func TestMaxBodySize(t *testing.T) {
maxSize := int64(100)
handler := MaxBodySize(maxSize)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
name string
contentLength int64
expectedStatus int
}{
{
name: "within limit",
contentLength: 50,
expectedStatus: http.StatusOK,
},
{
name: "at limit",
contentLength: 100,
expectedStatus: http.StatusOK,
},
{
name: "exceeds limit",
contentLength: 150,
expectedStatus: http.StatusRequestEntityTooLarge,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/test", nil)
req.ContentLength = tt.contentLength
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tt.expectedStatus {
t.Errorf("MaxBodySize() status = %d, want %d", rr.Code, tt.expectedStatus)
}
})
}
}
func TestTokenAuth(t *testing.T) {
t.Run("disabled auth allows all requests", func(t *testing.T) {
ta, err := NewTokenAuth(false)
if err != nil {
t.Fatalf("NewTokenAuth() error = %v", err)
}
handler := ta.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected OK with disabled auth, got %d", rr.Code)
}
})
t.Run("enabled auth requires token", func(t *testing.T) {
ta, err := NewTokenAuth(true)
if err != nil {
t.Fatalf("NewTokenAuth() error = %v", err)
}
handler := ta.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Without token
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected Unauthorized without token, got %d", rr.Code)
}
// With correct token in X-Auth-Token header
req = httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Auth-Token", ta.Token())
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected OK with correct token, got %d", rr.Code)
}
// With correct token in Authorization header
req = httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+ta.Token())
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected OK with Bearer token, got %d", rr.Code)
}
})
t.Run("exempt paths skip auth", func(t *testing.T) {
ta, err := NewTokenAuth(true)
if err != nil {
t.Fatalf("NewTokenAuth() error = %v", err)
}
handler := ta.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
exemptPaths := []string{"/health", "/api/health", "/api/ready"}
for _, path := range exemptPaths {
req := httptest.NewRequest("GET", path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected OK for exempt path %s, got %d", path, rr.Code)
}
}
})
}
func TestExpensiveOperationLimiter(t *testing.T) {
limiter := NewExpensiveOperationLimiter()
// First rebuild should be allowed
if !limiter.CanRebuild() {
t.Error("First rebuild should be allowed")
}
// Immediate second rebuild should be blocked
if limiter.CanRebuild() {
t.Error("Immediate second rebuild should be blocked")
}
}
func TestRequestID(t *testing.T) {
handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request ID is in context
id := GetRequestID(r.Context())
if id == "" {
t.Error("Request ID should be set in context")
}
w.WriteHeader(http.StatusOK)
}))
t.Run("generates new request ID", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Header().Get("X-Request-ID") == "" {
t.Error("X-Request-ID header should be set")
}
})
t.Run("uses existing request ID", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Request-ID", "test-id-12345")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Header().Get("X-Request-ID") != "test-id-12345" {
t.Errorf("Expected X-Request-ID to be test-id-12345, got %s", rr.Header().Get("X-Request-ID"))
}
})
}
func TestRequireJSONContentType(t *testing.T) {
handler := RequireJSONContentType(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
name string
method string
contentType string
expectedStatus int
}{
{
name: "GET request without content-type",
method: "GET",
contentType: "",
expectedStatus: http.StatusOK,
},
{
name: "POST with application/json",
method: "POST",
contentType: "application/json",
expectedStatus: http.StatusOK,
},
{
name: "POST with application/json; charset=utf-8",
method: "POST",
contentType: "application/json; charset=utf-8",
expectedStatus: http.StatusOK,
},
{
name: "POST without content-type (empty body)",
method: "POST",
contentType: "",
expectedStatus: http.StatusOK,
},
{
name: "POST with text/plain rejected",
method: "POST",
contentType: "text/plain",
expectedStatus: http.StatusUnsupportedMediaType,
},
{
name: "PUT with application/xml rejected",
method: "PUT",
contentType: "application/xml",
expectedStatus: http.StatusUnsupportedMediaType,
},
{
name: "PATCH with form-urlencoded rejected",
method: "PATCH",
contentType: "application/x-www-form-urlencoded",
expectedStatus: http.StatusUnsupportedMediaType,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/test", nil)
if tt.contentType != "" {
req.Header.Set("Content-Type", tt.contentType)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, rr.Code)
}
})
}
}
func TestValidateProjectName(t *testing.T) {
tests := []struct {
name string
project string
wantError bool
}{
{
name: "empty project allowed",
project: "",
wantError: false,
},
{
name: "simple project name",
project: "my-project",
wantError: false,
},
{
name: "project with path",
project: "org/my-project",
wantError: false,
},
{
name: "project with underscore",
project: "my_project_v2",
wantError: false,
},
{
name: "project with dot",
project: "my.project.name",
wantError: false,
},
{
name: "path traversal attack",
project: "../../../etc/passwd",
wantError: true,
},
{
name: "hidden path traversal",
project: "project/../../secret",
wantError: true,
},
{
name: "shell injection attempt",
project: "project; rm -rf /",
wantError: true,
},
{
name: "backtick injection",
project: "project`whoami`",
wantError: true,
},
{
name: "special characters",
project: "project$HOME",
wantError: true,
},
{
name: "too long project name",
project: string(make([]byte, 501)),
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateProjectName(tt.project)
if tt.wantError && err == nil {
t.Errorf("Expected error for project %q, got nil", tt.project)
}
if !tt.wantError && err != nil {
t.Errorf("Unexpected error for project %q: %v", tt.project, err)
}
})
}
}
func TestBulkOperationLimiter(t *testing.T) {
limiter := NewBulkOperationLimiter(1) // 1 second cooldown for testing
// First operation should be allowed
if !limiter.CanExecute() {
t.Error("First bulk operation should be allowed")
}
// Immediate second operation should be blocked
if limiter.CanExecute() {
t.Error("Immediate second bulk operation should be blocked")
}
// Check cooldown remaining
remaining := limiter.CooldownRemaining()
if remaining <= 0 || remaining > 1 {
t.Errorf("Expected cooldown remaining between 0-1 seconds, got %d", remaining)
}
// Check time since last op
since := limiter.TimeSinceLastOp()
if since < 0 || since > 1 {
t.Errorf("Expected time since last op between 0-1 seconds, got %d", since)
}
}
func TestSecurityHeaders_CSP(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Check CSP header is set
csp := rr.Header().Get("Content-Security-Policy")
if csp == "" {
t.Error("Content-Security-Policy header should be set")
}
if csp != "default-src 'self'" {
t.Errorf("Expected CSP to be \"default-src 'self'\", got %q", csp)
}
// Check Permissions-Policy header
pp := rr.Header().Get("Permissions-Policy")
if pp == "" {
t.Error("Permissions-Policy header should be set")
}
}
func TestSecurityHeaders_Preflight(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("OPTIONS", "/test", nil)
req.Header.Set("Origin", "http://localhost:3000")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// OPTIONS should return 204 No Content
if rr.Code != http.StatusNoContent {
t.Errorf("Expected status 204 for OPTIONS, got %d", rr.Code)
}
// CORS headers should be set for allowed origin
if rr.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" {
t.Errorf("CORS origin should be set for allowed origin")
}
if rr.Header().Get("Access-Control-Allow-Methods") == "" {
t.Error("Access-Control-Allow-Methods should be set")
}
}
+227
View File
@@ -0,0 +1,227 @@
// Package worker provides the main worker service for claude-mnemonic.
package worker
import (
"net/http"
"sync"
"time"
)
// RateLimiter implements a token bucket rate limiter.
type RateLimiter struct {
rate float64 // tokens per second
burst int // maximum burst size
mu sync.Mutex // protects following fields
tokens float64 // current tokens
lastUpdate time.Time // last token update time
requests int64 // total requests
rejected int64 // rejected requests
}
// LastUpdateTime returns the last update time.
// Thread-safe - acquires the limiter's lock.
func (rl *RateLimiter) LastUpdateTime() time.Time {
rl.mu.Lock()
defer rl.mu.Unlock()
return rl.lastUpdate
}
// lastUpdateTimeUnlocked returns the last update time without locking.
// Caller must hold rl.mu.
func (rl *RateLimiter) lastUpdateTimeUnlocked() time.Time {
return rl.lastUpdate
}
// NewRateLimiter creates a new rate limiter.
// rate is the number of requests per second to allow.
// burst is the maximum burst of requests to allow.
func NewRateLimiter(rate float64, burst int) *RateLimiter {
return &RateLimiter{
rate: rate,
burst: burst,
tokens: float64(burst),
lastUpdate: time.Now(),
}
}
// Allow checks if a request should be allowed.
// Returns true if the request is allowed, false if rate limited.
func (rl *RateLimiter) Allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.requests++
// Calculate tokens added since last update
now := time.Now()
elapsed := now.Sub(rl.lastUpdate).Seconds()
rl.tokens += elapsed * rl.rate
if rl.tokens > float64(rl.burst) {
rl.tokens = float64(rl.burst)
}
rl.lastUpdate = now
// Check if we have a token available
if rl.tokens >= 1 {
rl.tokens--
return true
}
rl.rejected++
return false
}
// Stats returns rate limiter statistics.
func (rl *RateLimiter) Stats() map[string]any {
rl.mu.Lock()
defer rl.mu.Unlock()
return map[string]any{
"rate": rl.rate,
"burst": rl.burst,
"current_tokens": rl.tokens,
"total_requests": rl.requests,
"rejected": rl.rejected,
"rejection_rate": float64(rl.rejected) / max(float64(rl.requests), 1),
}
}
// RateLimitMiddleware creates middleware that applies rate limiting.
// Uses a shared rate limiter for all requests.
func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// PerClientRateLimiter implements per-client rate limiting.
type PerClientRateLimiter struct {
rate float64
burst int
clients map[string]*RateLimiter
mu sync.Mutex
// Cleanup settings
cleanupInterval time.Duration
maxIdleTime time.Duration
lastCleanup time.Time
}
// NewPerClientRateLimiter creates a new per-client rate limiter.
func NewPerClientRateLimiter(rate float64, burst int) *PerClientRateLimiter {
return &PerClientRateLimiter{
rate: rate,
burst: burst,
clients: make(map[string]*RateLimiter),
cleanupInterval: 5 * time.Minute,
maxIdleTime: 10 * time.Minute,
lastCleanup: time.Now(),
}
}
// getLimiter returns a rate limiter for the given client key.
func (pcrl *PerClientRateLimiter) getLimiter(key string) *RateLimiter {
pcrl.mu.Lock()
defer pcrl.mu.Unlock()
// Periodic cleanup of idle clients
if time.Since(pcrl.lastCleanup) > pcrl.cleanupInterval {
pcrl.cleanupLocked()
}
limiter, exists := pcrl.clients[key]
if !exists {
limiter = NewRateLimiter(pcrl.rate, pcrl.burst)
pcrl.clients[key] = limiter
}
return limiter
}
// cleanupLocked removes idle limiters. Must be called with lock held.
// Uses consistent lock ordering: always acquire limiter.mu while holding pcrl.mu.
// This is safe because the limiter.mu critical section is brief (just reading lastUpdate).
func (pcrl *PerClientRateLimiter) cleanupLocked() {
now := time.Now()
keysToDelete := make([]string, 0)
// Check each limiter while holding pcrl.mu
// We briefly acquire limiter.mu but the critical section is minimal
for key, limiter := range pcrl.clients {
limiter.mu.Lock()
lastUpdate := limiter.lastUpdateTimeUnlocked()
limiter.mu.Unlock()
if now.Sub(lastUpdate) > pcrl.maxIdleTime {
keysToDelete = append(keysToDelete, key)
}
}
// Delete collected keys
for _, key := range keysToDelete {
delete(pcrl.clients, key)
}
pcrl.lastCleanup = now
}
// Allow checks if a request from the given client should be allowed.
func (pcrl *PerClientRateLimiter) Allow(clientKey string) bool {
return pcrl.getLimiter(clientKey).Allow()
}
// Stats returns aggregate statistics.
// Uses two-phase approach to avoid nested lock acquisition.
func (pcrl *PerClientRateLimiter) Stats() map[string]any {
// Phase 1: Collect limiters under pcrl.mu
pcrl.mu.Lock()
rate := pcrl.rate
burst := pcrl.burst
activeClients := len(pcrl.clients)
limiters := make([]*RateLimiter, 0, activeClients)
for _, limiter := range pcrl.clients {
limiters = append(limiters, limiter)
}
pcrl.mu.Unlock()
// Phase 2: Collect stats from each limiter (only acquiring limiter.mu, not pcrl.mu)
var totalRequests, totalRejected int64
for _, limiter := range limiters {
limiter.mu.Lock()
totalRequests += limiter.requests
totalRejected += limiter.rejected
limiter.mu.Unlock()
}
return map[string]any{
"rate": rate,
"burst": burst,
"active_clients": activeClients,
"total_requests": totalRequests,
"total_rejected": totalRejected,
}
}
// PerClientRateLimitMiddleware creates middleware that applies per-client rate limiting.
// Uses X-Forwarded-For or RemoteAddr to identify clients.
func PerClientRateLimitMiddleware(limiter *PerClientRateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get client identifier (prefer X-Real-IP from RealIP middleware)
clientKey := r.RemoteAddr
if xff := r.Header.Get("X-Real-IP"); xff != "" {
clientKey = xff
}
if !limiter.Allow(clientKey) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
+377 -36
View File
@@ -4,11 +4,15 @@ package sdk
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
json "github.com/goccy/go-json" json "github.com/goccy/go-json"
@@ -20,8 +24,178 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// CircuitBreaker implements a simple circuit breaker pattern for CLI calls.
type CircuitBreaker struct {
failures int64 // Current failure count
lastFailure int64 // Unix timestamp of last failure
threshold int64 // Number of failures before opening
resetTimeout int64 // Seconds to wait before trying again
state int32 // 0=closed, 1=open, 2=half-open
}
const (
circuitClosed int32 = 0
circuitOpen int32 = 1
circuitHalfOpen int32 = 2
)
// NewCircuitBreaker creates a new circuit breaker.
func NewCircuitBreaker(threshold int64, resetTimeout int64) *CircuitBreaker {
return &CircuitBreaker{
threshold: threshold,
resetTimeout: resetTimeout,
}
}
// Allow checks if a request should be allowed through.
func (cb *CircuitBreaker) Allow() bool {
state := atomic.LoadInt32(&cb.state)
if state == circuitClosed {
return true
}
if state == circuitOpen {
// Check if reset timeout has passed
lastFail := atomic.LoadInt64(&cb.lastFailure)
if time.Now().Unix()-lastFail > cb.resetTimeout {
// Transition to half-open
atomic.CompareAndSwapInt32(&cb.state, circuitOpen, circuitHalfOpen)
return true
}
return false
}
// Half-open: allow one request through
return true
}
// RecordSuccess records a successful call.
func (cb *CircuitBreaker) RecordSuccess() {
atomic.StoreInt64(&cb.failures, 0)
atomic.StoreInt32(&cb.state, circuitClosed)
}
// RecordFailure records a failed call.
func (cb *CircuitBreaker) RecordFailure() {
failures := atomic.AddInt64(&cb.failures, 1)
atomic.StoreInt64(&cb.lastFailure, time.Now().Unix())
if failures >= cb.threshold {
atomic.StoreInt32(&cb.state, circuitOpen)
log.Warn().Int64("failures", failures).Msg("Circuit breaker opened - Claude CLI calls temporarily disabled")
}
}
// State returns the current state as a string.
func (cb *CircuitBreaker) State() string {
switch atomic.LoadInt32(&cb.state) {
case circuitOpen:
return "open"
case circuitHalfOpen:
return "half-open"
default:
return "closed"
}
}
// CircuitBreakerMetrics contains metrics about the circuit breaker state.
type CircuitBreakerMetrics struct {
State string `json:"state"`
Failures int64 `json:"failures"`
Threshold int64 `json:"threshold"`
ResetTimeoutSecs int64 `json:"reset_timeout_secs"`
LastFailureUnix int64 `json:"last_failure_unix,omitempty"`
SecondsUntilReset int64 `json:"seconds_until_reset,omitempty"`
}
// Metrics returns the current metrics of the circuit breaker.
func (cb *CircuitBreaker) Metrics() CircuitBreakerMetrics {
failures := atomic.LoadInt64(&cb.failures)
lastFail := atomic.LoadInt64(&cb.lastFailure)
state := cb.State()
metrics := CircuitBreakerMetrics{
State: state,
Failures: failures,
Threshold: cb.threshold,
ResetTimeoutSecs: cb.resetTimeout,
}
if lastFail > 0 {
metrics.LastFailureUnix = lastFail
if state == "open" {
remaining := cb.resetTimeout - (time.Now().Unix() - lastFail)
if remaining > 0 {
metrics.SecondsUntilReset = remaining
}
}
}
return metrics
}
// RequestDeduplicator tracks recent requests to prevent duplicates.
type RequestDeduplicator struct {
seen map[string]int64 // hash -> timestamp
mu sync.RWMutex
ttlSecs int64
maxSize int
}
// NewRequestDeduplicator creates a new deduplicator.
func NewRequestDeduplicator(ttlSecs int64, maxSize int) *RequestDeduplicator {
return &RequestDeduplicator{
seen: make(map[string]int64),
ttlSecs: ttlSecs,
maxSize: maxSize,
}
}
// IsDuplicate checks if a request hash was seen recently.
func (d *RequestDeduplicator) IsDuplicate(hash string) bool {
now := time.Now().Unix()
d.mu.RLock()
ts, exists := d.seen[hash]
d.mu.RUnlock()
if exists && now-ts < d.ttlSecs {
return true
}
return false
}
// Record marks a request hash as seen.
func (d *RequestDeduplicator) Record(hash string) {
now := time.Now().Unix()
d.mu.Lock()
defer d.mu.Unlock()
// Evict old entries if at capacity
if len(d.seen) >= d.maxSize {
threshold := now - d.ttlSecs
for k, ts := range d.seen {
if ts < threshold {
delete(d.seen, k)
}
}
}
d.seen[hash] = now
}
// hashRequest creates a hash of a request for deduplication.
func hashRequest(toolName, input, output string) string {
h := sha256.New()
h.Write([]byte(toolName))
h.Write([]byte(input))
h.Write([]byte(output[:min(len(output), 1000)])) // Only hash first 1000 chars of output
return hex.EncodeToString(h.Sum(nil))[:16] // Short hash is sufficient
}
// BroadcastFunc is a callback for broadcasting events to SSE clients. // BroadcastFunc is a callback for broadcasting events to SSE clients.
type BroadcastFunc func(event map[string]interface{}) type BroadcastFunc func(event map[string]any)
// SyncObservationFunc is a callback for syncing observations to vector DB. // SyncObservationFunc is a callback for syncing observations to vector DB.
type SyncObservationFunc func(obs *models.Observation) type SyncObservationFunc func(obs *models.Observation)
@@ -29,6 +203,10 @@ type SyncObservationFunc func(obs *models.Observation)
// SyncSummaryFunc is a callback for syncing summaries to vector DB. // SyncSummaryFunc is a callback for syncing summaries to vector DB.
type SyncSummaryFunc func(summary *models.SessionSummary) type SyncSummaryFunc func(summary *models.SessionSummary)
// MaxVectorSyncWorkers is the maximum number of concurrent vector sync operations.
// This prevents unbounded goroutine spawning during high-volume observation ingestion.
const MaxVectorSyncWorkers = 8
// Processor handles SDK agent processing of observations and summaries using Claude Code CLI. // Processor handles SDK agent processing of observations and summaries using Claude Code CLI.
type Processor struct { type Processor struct {
claudePath string claudePath string
@@ -40,6 +218,14 @@ type Processor struct {
syncSummaryFunc SyncSummaryFunc syncSummaryFunc SyncSummaryFunc
// Semaphore to limit concurrent Claude CLI calls (prevents API overload) // Semaphore to limit concurrent Claude CLI calls (prevents API overload)
sem chan struct{} sem chan struct{}
// Circuit breaker for CLI failures
circuitBreaker *CircuitBreaker
// Request deduplicator to prevent duplicate processing
deduplicator *RequestDeduplicator
// Bounded worker pool for vector sync operations
vectorSyncChan chan *models.Observation
vectorSyncWg sync.WaitGroup
vectorSyncDone chan struct{}
} }
// SetBroadcastFunc sets the broadcast callback for SSE events. // SetBroadcastFunc sets the broadcast callback for SSE events.
@@ -58,7 +244,7 @@ func (p *Processor) SetSyncSummaryFunc(fn SyncSummaryFunc) {
} }
// broadcast sends an event via the broadcast callback if set. // broadcast sends an event via the broadcast callback if set.
func (p *Processor) broadcast(event map[string]interface{}) { func (p *Processor) broadcast(event map[string]any) {
if p.broadcastFunc != nil { if p.broadcastFunc != nil {
p.broadcastFunc(event) p.broadcastFunc(event)
} }
@@ -94,9 +280,65 @@ func NewProcessor(observationStore *gorm.ObservationStore, summaryStore *gorm.Su
observationStore: observationStore, observationStore: observationStore,
summaryStore: summaryStore, summaryStore: summaryStore,
sem: make(chan struct{}, MaxConcurrentCLICalls), sem: make(chan struct{}, MaxConcurrentCLICalls),
circuitBreaker: NewCircuitBreaker(5, 60), // Open after 5 failures, reset after 60s
deduplicator: NewRequestDeduplicator(300, 1000), // 5-minute TTL, 1000 max entries
vectorSyncChan: make(chan *models.Observation, MaxVectorSyncWorkers*2), // Buffered channel
vectorSyncDone: make(chan struct{}),
}, nil }, nil
} }
// StartVectorSyncWorkers starts the bounded worker pool for vector sync operations.
// Call this after setting the sync function via SetSyncObservationFunc.
func (p *Processor) StartVectorSyncWorkers() {
for i := 0; i < MaxVectorSyncWorkers; i++ {
p.vectorSyncWg.Add(1)
go p.vectorSyncWorker()
}
log.Info().Int("workers", MaxVectorSyncWorkers).Msg("Vector sync worker pool started")
}
// StopVectorSyncWorkers gracefully stops the worker pool.
func (p *Processor) StopVectorSyncWorkers() {
close(p.vectorSyncDone)
p.vectorSyncWg.Wait()
log.Info().Msg("Vector sync worker pool stopped")
}
// vectorSyncWorker is a worker goroutine that processes vector sync requests.
func (p *Processor) vectorSyncWorker() {
defer p.vectorSyncWg.Done()
for {
select {
case <-p.vectorSyncDone:
// Drain remaining items before exiting
for {
select {
case obs := <-p.vectorSyncChan:
if p.syncObservationFunc != nil {
p.syncObservationFunc(obs)
}
default:
return
}
}
case obs := <-p.vectorSyncChan:
if p.syncObservationFunc != nil {
p.syncObservationFunc(obs)
}
}
}
}
// CircuitBreakerState returns the current state of the circuit breaker.
func (p *Processor) CircuitBreakerState() string {
return p.circuitBreaker.State()
}
// CircuitBreakerMetrics returns detailed metrics about the circuit breaker.
func (p *Processor) CircuitBreakerMetrics() CircuitBreakerMetrics {
return p.circuitBreaker.Metrics()
}
// IsAvailable checks if the Claude CLI is available for processing. // IsAvailable checks if the Claude CLI is available for processing.
func (p *Processor) IsAvailable() bool { func (p *Processor) IsAvailable() bool {
_, err := os.Stat(p.claudePath) _, err := os.Stat(p.claudePath)
@@ -104,7 +346,7 @@ func (p *Processor) IsAvailable() bool {
} }
// ProcessObservation processes a single tool observation and extracts insights. // ProcessObservation processes a single tool observation and extracts insights.
func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, project string, toolName string, toolInput, toolResponse interface{}, promptNumber int, cwd string) error { func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, project string, toolName string, toolInput, toolResponse any, promptNumber int, cwd string) error {
// Skip certain tools that aren't worth processing // Skip certain tools that aren't worth processing
if shouldSkipTool(toolName) { if shouldSkipTool(toolName) {
log.Info().Str("tool", toolName).Msg("Skipping tool (not interesting for memory)") log.Info().Str("tool", toolName).Msg("Skipping tool (not interesting for memory)")
@@ -121,11 +363,23 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec
return nil return nil
} }
// Check for duplicate request within TTL window
reqHash := hashRequest(toolName, inputStr, outputStr)
if p.deduplicator.IsDuplicate(reqHash) {
log.Debug().Str("tool", toolName).Msg("Skipping duplicate request (dedup)")
return nil
}
// Check circuit breaker before making CLI call
if !p.circuitBreaker.Allow() {
log.Warn().Str("tool", toolName).Msg("Circuit breaker open - skipping CLI call")
return fmt.Errorf("circuit breaker open")
}
log.Info().Str("tool", toolName).Msg("Processing tool execution with Claude CLI") log.Info().Str("tool", toolName).Msg("Processing tool execution with Claude CLI")
// Note: Removed the "file already has observations" check // Record this request to prevent duplicates
// Each tool execution can produce unique insights even for the same file p.deduplicator.Record(reqHash)
// Similarity-based deduplication will handle true duplicates
// Build the prompt // Build the prompt
exec := ToolExecution{ exec := ToolExecution{
@@ -147,9 +401,11 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec
// Call Claude Code CLI // Call Claude Code CLI
response, err := p.callClaudeCLI(ctx, prompt) response, err := p.callClaudeCLI(ctx, prompt)
if err != nil { if err != nil {
p.circuitBreaker.RecordFailure()
log.Error().Err(err).Str("tool", toolName).Msg("Failed to call Claude CLI for observation") log.Error().Err(err).Str("tool", toolName).Msg("Failed to call Claude CLI for observation")
return err return err
} }
p.circuitBreaker.RecordSuccess()
// Parse observations from response // Parse observations from response
observations := ParseObservations(response, sdkSessionID) observations := ParseObservations(response, sdkSessionID)
@@ -200,16 +456,26 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec
Int("trackedFiles", len(obs.FileMtimes)). Int("trackedFiles", len(obs.FileMtimes)).
Msg("Observation stored") Msg("Observation stored")
// Sync to vector DB if callback is set // Sync to vector DB via bounded worker pool (non-blocking to reduce latency)
if p.syncObservationFunc != nil { if p.syncObservationFunc != nil && p.vectorSyncChan != nil {
fullObs := models.NewObservation(sdkSessionID, project, obs, promptNumber, 0) fullObs := models.NewObservation(sdkSessionID, project, obs, promptNumber, 0)
fullObs.ID = id fullObs.ID = id
fullObs.CreatedAtEpoch = createdAtEpoch fullObs.CreatedAtEpoch = createdAtEpoch
p.syncObservationFunc(fullObs) // Non-blocking send to worker pool - drops if channel is full
select {
case p.vectorSyncChan <- fullObs:
// Sent to worker pool
default:
// Channel full, fall back to direct sync in goroutine (bounded by channel buffer)
log.Debug().Int64("obs_id", id).Msg("Vector sync channel full, using fallback goroutine")
go func(obsToSync *models.Observation) {
p.syncObservationFunc(obsToSync)
}(fullObs)
}
} }
// Broadcast new observation event for dashboard refresh // Broadcast new observation event for dashboard refresh
p.broadcast(map[string]interface{}{ p.broadcast(map[string]any{
"type": "observation", "type": "observation",
"action": "created", "action": "created",
"id": id, "id": id,
@@ -311,7 +577,7 @@ func (p *Processor) ProcessSummary(ctx context.Context, sessionDBID int64, sdkSe
} }
// Broadcast new summary event for dashboard refresh // Broadcast new summary event for dashboard refresh
p.broadcast(map[string]interface{}{ p.broadcast(map[string]any{
"type": "summary", "type": "summary",
"action": "created", "action": "created",
"id": id, "id": id,
@@ -321,8 +587,31 @@ func (p *Processor) ProcessSummary(ctx context.Context, sessionDBID int64, sdkSe
return nil return nil
} }
// MaxPromptSize is the maximum size of a prompt that can be passed to the Claude CLI.
// This prevents resource exhaustion from extremely large prompts.
const MaxPromptSize = 100 * 1024 // 100KB
// sanitizePrompt removes null bytes and control characters from a prompt.
// Keeps newlines, tabs, and carriage returns as they're valid in prompts.
func sanitizePrompt(s string) string {
return strings.Map(func(r rune) rune {
// Keep printable ASCII, extended Unicode, and common whitespace
if r >= 32 || r == '\n' || r == '\t' || r == '\r' {
return r
}
// Remove null bytes and other control characters
return -1
}, s)
}
// callClaudeCLI calls the Claude Code CLI with the given prompt. // callClaudeCLI calls the Claude Code CLI with the given prompt.
func (p *Processor) callClaudeCLI(ctx context.Context, prompt string) (string, error) { func (p *Processor) callClaudeCLI(ctx context.Context, prompt string) (string, error) {
// Validate and sanitize prompt
if len(prompt) > MaxPromptSize {
return "", fmt.Errorf("prompt exceeds maximum size of %d bytes", MaxPromptSize)
}
prompt = sanitizePrompt(prompt)
// Build the full prompt with system instructions // Build the full prompt with system instructions
fullPrompt := systemPrompt + "\n\n" + prompt fullPrompt := systemPrompt + "\n\n" + prompt
@@ -419,8 +708,11 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
return true return true
} }
// Skip if output indicates an error or empty result // Pre-compute lowercase strings once to avoid repeated allocations
lowerOutput := strings.ToLower(outputStr) lowerOutput := strings.ToLower(outputStr)
lowerInput := strings.ToLower(inputStr)
// Skip if output indicates an error or empty result
trivialOutputs := []string{ trivialOutputs := []string{
"no matches found", "no matches found",
"file not found", "file not found",
@@ -444,13 +736,13 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
// Skip reading config files that rarely contain project-specific insights // Skip reading config files that rarely contain project-specific insights
boringFiles := []string{ boringFiles := []string{
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
"go.sum", "Cargo.lock", "Gemfile.lock", "poetry.lock", "go.sum", "cargo.lock", "gemfile.lock", "poetry.lock",
".gitignore", ".dockerignore", ".eslintignore", ".gitignore", ".dockerignore", ".eslintignore",
"tsconfig.json", "jsconfig.json", "vite.config", "tsconfig.json", "jsconfig.json", "vite.config",
"tailwind.config", "postcss.config", "tailwind.config", "postcss.config",
} }
for _, boring := range boringFiles { for _, boring := range boringFiles {
if strings.Contains(inputStr, boring) { if strings.Contains(lowerInput, boring) {
return true return true
} }
} }
@@ -462,14 +754,14 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
} }
case "Bash": case "Bash":
// Skip simple status commands // Skip simple status commands (use pre-computed lowerInput)
boringCommands := []string{ boringCommands := []string{
"git status", "git diff", "git log", "git branch", "git status", "git diff", "git log", "git branch",
"ls ", "pwd", "echo ", "cat ", "which ", "type ", "ls ", "pwd", "echo ", "cat ", "which ", "type ",
"npm list", "npm outdated", "npm audit", "npm list", "npm outdated", "npm audit",
} }
for _, boring := range boringCommands { for _, boring := range boringCommands {
if strings.Contains(strings.ToLower(inputStr), boring) { if strings.Contains(lowerInput, boring) {
return true return true
} }
} }
@@ -479,7 +771,7 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
} }
// toJSONString converts an interface to a JSON string. // toJSONString converts an interface to a JSON string.
func toJSONString(v interface{}) string { func toJSONString(v any) string {
if v == nil { if v == nil {
return "" return ""
} }
@@ -495,36 +787,85 @@ func toJSONString(v interface{}) string {
// captureFileMtimes captures current modification times for tracked files. // captureFileMtimes captures current modification times for tracked files.
// Returns a map of absolute file paths to their mtime in epoch milliseconds. // Returns a map of absolute file paths to their mtime in epoch milliseconds.
// For large file lists (>10 files), uses parallel stat calls for better performance.
func captureFileMtimes(filesRead, filesModified []string, cwd string) map[string]int64 { func captureFileMtimes(filesRead, filesModified []string, cwd string) map[string]int64 {
mtimes := make(map[string]int64) // Combine all unique file paths
allPaths := make(map[string]struct{}, len(filesRead)+len(filesModified))
for _, path := range filesRead {
allPaths[path] = struct{}{}
}
for _, path := range filesModified {
allPaths[path] = struct{}{}
}
// Helper to get mtime for a file path // For small lists, use sequential processing (goroutine overhead not worth it)
getMtime := func(path string) (int64, bool) { if len(allPaths) <= 10 {
// Resolve relative paths against cwd return captureFileMtimesSequential(allPaths, cwd)
}
// For larger lists, parallelize with bounded concurrency
return captureFileMtimesParallel(allPaths, cwd)
}
// captureFileMtimesSequential captures mtimes sequentially (efficient for small lists).
func captureFileMtimesSequential(paths map[string]struct{}, cwd string) map[string]int64 {
mtimes := make(map[string]int64, len(paths))
for path := range paths {
absPath := path absPath := path
if !filepath.IsAbs(path) && cwd != "" { if !filepath.IsAbs(path) && cwd != "" {
absPath = filepath.Join(cwd, path) absPath = filepath.Join(cwd, path)
} }
info, err := os.Stat(absPath) info, err := os.Stat(absPath)
if err != nil { if err == nil {
return 0, false mtimes[path] = info.ModTime().UnixMilli()
}
return info.ModTime().UnixMilli(), true
}
// Capture mtimes for all read files
for _, path := range filesRead {
if mtime, ok := getMtime(path); ok {
mtimes[path] = mtime
} }
} }
// Capture mtimes for all modified files return mtimes
for _, path := range filesModified { }
if mtime, ok := getMtime(path); ok {
mtimes[path] = mtime // captureFileMtimesParallel captures mtimes in parallel with bounded concurrency.
} func captureFileMtimesParallel(paths map[string]struct{}, cwd string) map[string]int64 {
type mtimeResult struct {
path string
mtime int64
}
results := make(chan mtimeResult, len(paths))
sem := make(chan struct{}, 8) // Limit to 8 concurrent stat calls
var wg sync.WaitGroup
for path := range paths {
wg.Add(1)
go func(p string) {
defer wg.Done()
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
absPath := p
if !filepath.IsAbs(p) && cwd != "" {
absPath = filepath.Join(cwd, p)
}
info, err := os.Stat(absPath)
if err == nil {
results <- mtimeResult{path: p, mtime: info.ModTime().UnixMilli()}
}
}(path)
}
// Close results channel when all goroutines complete
go func() {
wg.Wait()
close(results)
}()
// Collect results
mtimes := make(map[string]int64, len(paths))
for res := range results {
mtimes[res.path] = res.mtime
} }
return mtimes return mtimes
+107
View File
@@ -974,3 +974,110 @@ func TestSyncSummaryFuncType(t *testing.T) {
} }
assert.NotNil(t, fn) assert.NotNil(t, fn)
} }
// TestSanitizePrompt tests prompt sanitization for CLI safety.
func TestSanitizePrompt(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "normal text",
input: "Hello, world!",
expected: "Hello, world!",
},
{
name: "text with newlines",
input: "Line 1\nLine 2\nLine 3",
expected: "Line 1\nLine 2\nLine 3",
},
{
name: "text with tabs",
input: "Key:\tValue",
expected: "Key:\tValue",
},
{
name: "text with carriage return",
input: "Line 1\r\nLine 2",
expected: "Line 1\r\nLine 2",
},
{
name: "text with null bytes",
input: "Hello\x00World",
expected: "HelloWorld",
},
{
name: "text with control characters",
input: "Hello\x01\x02\x03World",
expected: "HelloWorld",
},
{
name: "text with bell character",
input: "Hello\x07World",
expected: "HelloWorld",
},
{
name: "text with backspace",
input: "Hello\x08World",
expected: "HelloWorld",
},
{
name: "text with form feed",
input: "Hello\x0cWorld",
expected: "HelloWorld",
},
{
name: "text with escape",
input: "Hello\x1bWorld",
expected: "HelloWorld",
},
{
name: "unicode text",
input: "Hello 世界 🌍",
expected: "Hello 世界 🌍",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "only control characters",
input: "\x00\x01\x02\x03",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizePrompt(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// TestMaxPromptSize tests that MaxPromptSize is reasonable.
func TestMaxPromptSize(t *testing.T) {
assert.Equal(t, 100*1024, MaxPromptSize)
}
// BenchmarkSanitizePrompt benchmarks the sanitize function.
func BenchmarkSanitizePrompt(b *testing.B) {
prompt := "Analyze the following code:\n```go\nfunc main() {\n\tfmt.Println(\"Hello, World!\")\n}\n```\n\nPlease identify any issues."
b.ResetTimer()
for i := 0; i < b.N; i++ {
sanitizePrompt(prompt)
}
}
// BenchmarkSanitizePromptWithControlChars benchmarks sanitization with control characters.
func BenchmarkSanitizePromptWithControlChars(b *testing.B) {
prompt := "Hello\x00World\x01Test\x02Data\x03End"
b.ResetTimer()
for i := 0; i < b.N; i++ {
sanitizePrompt(prompt)
}
}
+671 -284
View File
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -5,6 +5,8 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"strings" "strings"
"github.com/rs/zerolog/log"
) )
//go:embed static/* //go:embed static/*
@@ -13,16 +15,22 @@ var staticFS embed.FS
// staticSubFS is the static subdirectory filesystem // staticSubFS is the static subdirectory filesystem
var staticSubFS fs.FS var staticSubFS fs.FS
// staticInitErr stores any error from static filesystem initialization
var staticInitErr error
func init() { func init() {
var err error staticSubFS, staticInitErr = fs.Sub(staticFS, "static")
staticSubFS, err = fs.Sub(staticFS, "static") if staticInitErr != nil {
if err != nil { log.Warn().Err(staticInitErr).Msg("Static filesystem initialization failed - dashboard will be unavailable")
panic("failed to create sub filesystem: " + err.Error())
} }
} }
// serveIndex serves the index.html file for the root path // serveIndex serves the index.html file for the root path
func serveIndex(w http.ResponseWriter, r *http.Request) { func serveIndex(w http.ResponseWriter, r *http.Request) {
if staticInitErr != nil {
http.Error(w, "Dashboard unavailable: static files not initialized", http.StatusServiceUnavailable)
return
}
content, err := fs.ReadFile(staticSubFS, "index.html") content, err := fs.ReadFile(staticSubFS, "index.html")
if err != nil { if err != nil {
http.Error(w, "Dashboard not found", http.StatusNotFound) http.Error(w, "Dashboard not found", http.StatusNotFound)
@@ -38,6 +46,10 @@ func serveIndex(w http.ResponseWriter, r *http.Request) {
// serveAssets serves static assets from the embedded filesystem // serveAssets serves static assets from the embedded filesystem
func serveAssets(w http.ResponseWriter, r *http.Request) { func serveAssets(w http.ResponseWriter, r *http.Request) {
if staticInitErr != nil {
http.Error(w, "Assets unavailable: static files not initialized", http.StatusServiceUnavailable)
return
}
// Strip the /assets/ prefix and serve the file // Strip the /assets/ prefix and serve the file
path := strings.TrimPrefix(r.URL.Path, "/") path := strings.TrimPrefix(r.URL.Path, "/")
+99
View File
@@ -2,6 +2,7 @@
package similarity package similarity
import ( import (
"math/bits"
"strings" "strings"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models" "github.com/lukaszraczylo/claude-mnemonic/pkg/models"
@@ -15,6 +16,17 @@ func ClusterObservations(observations []*models.Observation, similarityThreshold
return observations return observations
} }
// For small sets, use the simple O(n²) algorithm
if len(observations) <= 50 {
return clusterObservationsSimple(observations, similarityThreshold)
}
// For larger sets, use an optimized approach with early termination
return clusterObservationsOptimized(observations, similarityThreshold)
}
// clusterObservationsSimple is the simple O(n²) algorithm for small sets.
func clusterObservationsSimple(observations []*models.Observation, similarityThreshold float64) []*models.Observation {
// Extract terms for each observation // Extract terms for each observation
termSets := make([]map[string]bool, len(observations)) termSets := make([]map[string]bool, len(observations))
for i, obs := range observations { for i, obs := range observations {
@@ -51,6 +63,93 @@ func ClusterObservations(observations []*models.Observation, similarityThreshold
return result return result
} }
// clusterObservationsOptimized uses MinHash-based approximation for large sets.
// This reduces complexity from O(n²) to approximately O(n*k) where k is the number of hash functions.
func clusterObservationsOptimized(observations []*models.Observation, similarityThreshold float64) []*models.Observation {
n := len(observations)
// Extract terms for each observation and compute a signature
type termSetWithSig struct {
terms map[string]bool
signature uint64 // Simple hash signature for fast comparison
}
termSets := make([]termSetWithSig, n)
for i, obs := range observations {
terms := ExtractObservationTerms(obs)
termSets[i] = termSetWithSig{
terms: terms,
signature: computeTermSignature(terms),
}
}
// Track which observations are already clustered
clustered := make([]bool, n)
result := make([]*models.Observation, 0, n/2) // Pre-allocate assuming ~50% are unique
for i := 0; i < n; i++ {
if clustered[i] {
continue
}
// This observation becomes the representative of its cluster
result = append(result, observations[i])
clustered[i] = true
// Use signature for fast pre-filtering
sigI := termSets[i].signature
termsI := termSets[i].terms
// Find all similar observations and mark them as clustered
for j := i + 1; j < n; j++ {
if clustered[j] {
continue
}
// Quick signature comparison - if signatures are very different, skip detailed comparison
sigJ := termSets[j].signature
sigDiff := sigI ^ sigJ
popCount := popCount64(sigDiff)
// If signatures differ significantly, similarity is likely low
// Skip detailed comparison for very different signatures
if popCount > 32 { // More than half of bits differ
continue
}
// Full Jaccard comparison for candidates
similarity := JaccardSimilarity(termsI, termSets[j].terms)
if similarity >= similarityThreshold {
clustered[j] = true
}
}
}
return result
}
// computeTermSignature creates a quick hash signature for term sets.
// Used for fast pre-filtering in the optimized clustering algorithm.
func computeTermSignature(terms map[string]bool) uint64 {
var sig uint64
for term := range terms {
// Simple hash using FNV-1a inspired approach
h := uint64(14695981039346656037)
for i := 0; i < len(term); i++ {
h ^= uint64(term[i])
h *= 1099511628211
}
sig ^= h
}
return sig
}
// popCount64 counts the number of set bits in a 64-bit integer.
// Uses the stdlib bits.OnesCount64 which may use CPU POPCNT instruction.
func popCount64(x uint64) int {
return bits.OnesCount64(x)
}
// IsSimilarToAny checks if a new observation is similar to any existing observation. // IsSimilarToAny checks if a new observation is similar to any existing observation.
// Returns true if similarity to any existing observation exceeds the threshold. // Returns true if similarity to any existing observation exceeds the threshold.
func IsSimilarToAny(newObs *models.Observation, existing []*models.Observation, similarityThreshold float64) bool { func IsSimilarToAny(newObs *models.Observation, existing []*models.Observation, similarityThreshold float64) bool {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "claude-mnemonic-dashboard", "name": "claude-mnemonic-dashboard",
"version": "8fe9ea5-dirty", "version": "v0.10.5-1-g7ab4b07-dirty",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "claude-mnemonic-dashboard", "name": "claude-mnemonic-dashboard",
"version": "8fe9ea5-dirty", "version": "v0.10.5-1-g7ab4b07-dirty",
"dependencies": { "dependencies": {
"vis-data": "^7.1.9", "vis-data": "^7.1.9",
"vis-network": "^9.1.9", "vis-network": "^9.1.9",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "claude-mnemonic-dashboard", "name": "claude-mnemonic-dashboard",
"version": "8fe9ea5-dirty", "version": "v0.10.5-1-g7ab4b07-dirty",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+17 -5
View File
@@ -8,6 +8,7 @@ import Card from './Card.vue'
import IconBox from './IconBox.vue' import IconBox from './IconBox.vue'
import Badge from './Badge.vue' import Badge from './Badge.vue'
import RelationGraph from './RelationGraph.vue' import RelationGraph from './RelationGraph.vue'
import ScoreBreakdown from './ScoreBreakdown.vue'
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
const props = defineProps<{ const props = defineProps<{
@@ -95,6 +96,9 @@ const relationsLoading = ref(false)
const relationsExpanded = ref(false) const relationsExpanded = ref(false)
const showGraph = ref(false) const showGraph = ref(false)
// Score breakdown state
const showScoreBreakdown = ref(false)
const hasRelations = computed(() => relations.value.length > 0) const hasRelations = computed(() => relations.value.length > 0)
const relationCount = computed(() => relations.value.length) const relationCount = computed(() => relations.value.length)
@@ -350,14 +354,15 @@ const splitPath = (path: string, components = 3) => {
<i class="fas fa-thumbs-up text-sm" /> <i class="fas fa-thumbs-up text-sm" />
</button> </button>
<span <button
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-400 flex items-center gap-1 transition-all duration-300" @click="showScoreBreakdown = true"
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-400 flex items-center gap-1 transition-all duration-300 hover:bg-purple-500/20 hover:text-purple-300 cursor-pointer"
:class="{ 'text-green-400': localScore !== null && localScore > (observation.importance_score || 1), 'text-red-400': localScore !== null && localScore < (observation.importance_score || 1) }" :class="{ 'text-green-400': localScore !== null && localScore > (observation.importance_score || 1), 'text-red-400': localScore !== null && localScore < (observation.importance_score || 1) }"
:title="`Importance Score: ${currentScore.toFixed(3)}\nRetrieval Count: ${observation.retrieval_count || 0}`" :title="`Importance Score: ${currentScore.toFixed(3)}\nRetrieval Count: ${observation.retrieval_count || 0}\nClick for details`"
> >
<i class="fas fa-scale-balanced text-amber-500/60" /> <i class="fas fa-chart-bar text-purple-500/60" />
{{ currentScore.toFixed(2) }} {{ currentScore.toFixed(2) }}
</span> </button>
<button <button
@click="submitFeedback(-1)" @click="submitFeedback(-1)"
@@ -383,5 +388,12 @@ const splitPath = (path: string, components = 3) => {
@close="showGraph = false" @close="showGraph = false"
@navigate-to="handleNavigateTo" @navigate-to="handleNavigateTo"
/> />
<!-- Score Breakdown Modal -->
<ScoreBreakdown
:observation-id="observation.id"
:show="showScoreBreakdown"
@close="showScoreBreakdown = false"
/>
</Card> </Card>
</template> </template>
+202
View File
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { fetchObservationScore, type ScoreBreakdown } from '@/utils/api'
import Card from './Card.vue'
const props = defineProps<{
observationId: number
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const data = ref<ScoreBreakdown | null>(null)
const loadScore = async () => {
if (!props.observationId) return
loading.value = true
error.value = null
try {
data.value = await fetchObservationScore(props.observationId)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load score breakdown'
} finally {
loading.value = false
}
}
// Load on mount and when ID changes
onMounted(() => {
if (props.show) loadScore()
})
watch(() => props.show, (newVal) => {
if (newVal) loadScore()
})
watch(() => props.observationId, () => {
if (props.show) loadScore()
})
// Score bar helper
const getScoreBarWidth = (value: number, max: number = 2) => {
return `${Math.min(100, Math.max(0, (value / max) * 100))}%`
}
// Score color helper
const getScoreColor = (value: number) => {
if (value >= 1.5) return 'bg-green-500'
if (value >= 1) return 'bg-amber-500'
if (value >= 0.5) return 'bg-orange-500'
return 'bg-red-500'
}
</script>
<template>
<!-- Modal Backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal Content -->
<div class="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<Card
gradient="bg-gradient-to-br from-purple-500/10 to-indigo-500/5"
border-class="border-purple-500/30"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-chart-bar text-purple-400" />
<h3 class="text-lg font-semibold text-purple-100">Score Breakdown</h3>
</div>
<button
@click="emit('close')"
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-2xl text-purple-400" />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="data" class="space-y-4">
<!-- Observation Info -->
<div class="p-3 bg-slate-800/50 rounded-lg">
<div class="text-xs text-slate-500 uppercase tracking-wide mb-1">Observation</div>
<div class="text-amber-100 font-medium">{{ data.observation.title || 'Untitled' }}</div>
<div class="flex items-center gap-2 mt-1 text-xs text-slate-400">
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded">{{ data.observation.type }}</span>
<span>{{ data.scoring.age_days.toFixed(1) }} days old</span>
</div>
</div>
<!-- Final Score -->
<div class="p-4 bg-gradient-to-r from-purple-500/20 to-indigo-500/10 rounded-lg border border-purple-500/30">
<div class="flex items-center justify-between">
<span class="text-sm text-slate-300">Final Score</span>
<span class="text-2xl font-bold text-purple-300">{{ data.scoring.final_score.toFixed(3) }}</span>
</div>
<div class="mt-2 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-500"
:class="getScoreColor(data.scoring.final_score)"
:style="{ width: getScoreBarWidth(data.scoring.final_score) }"
/>
</div>
</div>
<!-- Score Components -->
<div class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Score Components</div>
<!-- Type Weight -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-tag text-blue-400 w-4" />
<span class="text-slate-300">Type Weight</span>
</div>
<span class="font-mono text-blue-300">{{ data.scoring.type_weight.toFixed(2) }}</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.type_impact }}</p>
<!-- Recency Decay -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-clock text-cyan-400 w-4" />
<span class="text-slate-300">Recency Decay</span>
</div>
<span class="font-mono text-cyan-300">{{ data.scoring.recency_decay.toFixed(2) }}</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.recency_impact }}</p>
<!-- Core Score -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-star text-amber-400 w-4" />
<span class="text-slate-300">Core Score</span>
</div>
<span class="font-mono text-amber-300">{{ data.scoring.core_score.toFixed(3) }}</span>
</div>
<!-- Feedback Contribution -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-thumbs-up text-green-400 w-4" />
<span class="text-slate-300">Feedback</span>
</div>
<span class="font-mono" :class="data.scoring.feedback_contrib >= 0 ? 'text-green-300' : 'text-red-300'">
{{ data.scoring.feedback_contrib >= 0 ? '+' : '' }}{{ data.scoring.feedback_contrib.toFixed(3) }}
</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.feedback_impact }}</p>
<!-- Concept Contribution -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-tags text-purple-400 w-4" />
<span class="text-slate-300">Concepts</span>
</div>
<span class="font-mono text-purple-300">+{{ data.scoring.concept_contrib.toFixed(3) }}</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.concept_impact }}</p>
<!-- Retrieval Contribution -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-search text-indigo-400 w-4" />
<span class="text-slate-300">Retrieval</span>
</div>
<span class="font-mono text-indigo-300">+{{ data.scoring.retrieval_contrib.toFixed(3) }}</span>
</div>
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.retrieval_impact }}</p>
</div>
</div>
</Card>
</div>
</div>
</Teleport>
</template>
+271
View File
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { fetchSearchAnalytics, fetchRecentSearches, type SearchAnalytics, type RecentQuery } from '@/utils/api'
import Card from './Card.vue'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const analytics = ref<SearchAnalytics | null>(null)
const recentSearches = ref<RecentQuery[]>([])
const loadData = async () => {
if (!props.show) return
loading.value = true
error.value = null
try {
const [analyticsData, searchesData] = await Promise.all([
fetchSearchAnalytics(),
fetchRecentSearches(20)
])
analytics.value = analyticsData
recentSearches.value = searchesData
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load search analytics'
} finally {
loading.value = false
}
}
// Load on mount and when show changes
onMounted(() => {
if (props.show) loadData()
})
watch(() => props.show, (newVal) => {
if (newVal) loadData()
})
// Computed stats
const cacheHitRate = computed(() => {
if (!analytics.value || analytics.value.total_searches === 0) return 0
return (analytics.value.cache_hits / analytics.value.total_searches) * 100
})
const coalescedRate = computed(() => {
if (!analytics.value || analytics.value.total_searches === 0) return 0
return (analytics.value.coalesced_requests / analytics.value.total_searches) * 100
})
const errorRate = computed(() => {
if (!analytics.value || analytics.value.total_searches === 0) return 0
return (analytics.value.search_errors / analytics.value.total_searches) * 100
})
// Helper for latency color
const getLatencyColor = (ms: number) => {
if (ms < 10) return 'text-green-400'
if (ms < 50) return 'text-amber-400'
return 'text-red-400'
}
// Helper for formatting time ago
const formatTimeAgo = (isoDate: string) => {
const date = new Date(isoDate)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}
</script>
<template>
<!-- Modal Backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal Content -->
<div class="relative w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<Card
gradient="bg-gradient-to-br from-cyan-500/10 to-blue-500/5"
border-class="border-cyan-500/30"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-chart-line text-cyan-400" />
<h3 class="text-lg font-semibold text-cyan-100">Search Analytics</h3>
</div>
<button
@click="emit('close')"
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-2xl text-cyan-400" />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="analytics" class="space-y-6">
<!-- Overview Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Total Searches -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-2xl font-bold text-cyan-300">{{ analytics.total_searches.toLocaleString() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Total Searches</div>
</div>
<!-- Vector Searches -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-2xl font-bold text-purple-300">{{ analytics.vector_searches.toLocaleString() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Vector Searches</div>
</div>
<!-- Filter Searches -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-2xl font-bold text-blue-300">{{ analytics.filter_searches.toLocaleString() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Filter Searches</div>
</div>
<!-- Cache Hits -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-2xl font-bold text-green-300">{{ analytics.cache_hits.toLocaleString() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Cache Hits</div>
</div>
</div>
<!-- Performance Metrics -->
<div class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Performance Metrics</div>
<!-- Cache Hit Rate -->
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fas fa-database text-green-400 w-5" />
<span class="text-slate-300">Cache Hit Rate</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full bg-green-500 transition-all"
:style="{ width: `${cacheHitRate}%` }"
/>
</div>
<span class="font-mono text-green-300 w-16 text-right">{{ cacheHitRate.toFixed(1) }}%</span>
</div>
</div>
<!-- Coalesced Rate -->
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fas fa-compress-arrows-alt text-amber-400 w-5" />
<span class="text-slate-300">Coalesced Requests</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full bg-amber-500 transition-all"
:style="{ width: `${coalescedRate}%` }"
/>
</div>
<span class="font-mono text-amber-300 w-16 text-right">{{ coalescedRate.toFixed(1) }}%</span>
</div>
</div>
<!-- Error Rate -->
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fas fa-exclamation-circle text-red-400 w-5" />
<span class="text-slate-300">Error Rate</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full bg-red-500 transition-all"
:style="{ width: `${Math.min(100, errorRate)}%` }"
/>
</div>
<span class="font-mono text-red-300 w-16 text-right">{{ errorRate.toFixed(2) }}%</span>
</div>
</div>
</div>
<!-- Latency Stats -->
<div class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Latency</div>
<div class="grid grid-cols-3 gap-3">
<!-- Average Latency -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_latency_ms)">
{{ analytics.avg_latency_ms.toFixed(1) }}ms
</div>
<div class="text-xs text-slate-500">Average</div>
</div>
<!-- Vector Latency -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_vector_latency_ms)">
{{ analytics.avg_vector_latency_ms.toFixed(1) }}ms
</div>
<div class="text-xs text-slate-500">Vector</div>
</div>
<!-- Filter Latency -->
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_filter_latency_ms)">
{{ analytics.avg_filter_latency_ms.toFixed(1) }}ms
</div>
<div class="text-xs text-slate-500">Filter</div>
</div>
</div>
</div>
<!-- Recent Searches -->
<div v-if="recentSearches.length > 0" class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Recent Searches</div>
<div class="space-y-2 max-h-48 overflow-y-auto">
<div
v-for="(search, index) in recentSearches"
:key="index"
class="flex items-center gap-3 p-2 bg-slate-800/30 rounded-lg text-sm"
>
<i class="fas fa-search text-slate-500 text-xs" />
<span class="flex-1 text-slate-300 truncate" :title="search.query">{{ search.query }}</span>
<span v-if="search.project" class="text-xs text-amber-600/80 font-mono">{{ search.project.split('/').pop() }}</span>
<span v-if="search.type" class="text-xs text-cyan-500 bg-cyan-500/10 px-1.5 py-0.5 rounded">{{ search.type }}</span>
<span class="text-xs text-slate-500 font-mono">×{{ search.count }}</span>
<span class="text-xs text-slate-600">{{ formatTimeAgo(search.last_used) }}</span>
</div>
</div>
</div>
</div>
</Card>
</div>
</div>
</Teleport>
</template>
+68 -9
View File
@@ -2,6 +2,9 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Stats, SelfCheckResponse } from '@/types' import type { Stats, SelfCheckResponse } from '@/types'
import ProjectFilter from './ProjectFilter.vue' import ProjectFilter from './ProjectFilter.vue'
import SearchAnalytics from './SearchAnalytics.vue'
import SystemHealthDetails from './SystemHealthDetails.vue'
import TopObservations from './TopObservations.vue'
const props = defineProps<{ const props = defineProps<{
stats: Stats | null stats: Stats | null
@@ -19,6 +22,15 @@ defineEmits<{
// Collapse state - persisted in localStorage // Collapse state - persisted in localStorage
const isCollapsed = ref(localStorage.getItem('sidebar-collapsed') === 'true') const isCollapsed = ref(localStorage.getItem('sidebar-collapsed') === 'true')
// Search Analytics modal state
const showSearchAnalytics = ref(false)
// System Health Details modal state
const showHealthDetails = ref(false)
// Top Observations modal state
const showTopObservations = ref(false)
function toggleCollapse() { function toggleCollapse() {
isCollapsed.value = !isCollapsed.value isCollapsed.value = !isCollapsed.value
localStorage.setItem('sidebar-collapsed', String(isCollapsed.value)) localStorage.setItem('sidebar-collapsed', String(isCollapsed.value))
@@ -96,9 +108,18 @@ function getStatusColor(status: string): string {
<!-- Component Health --> <!-- Component Health -->
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50"> <div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center justify-between mb-3">
<i :class="['fas', overallHealthIcon, overallHealthColor]" /> <div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-white">System Health</h3> <i :class="['fas', overallHealthIcon, overallHealthColor]" />
<h3 class="text-sm font-semibold text-white">System Health</h3>
</div>
<button
@click="showHealthDetails = true"
class="text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
title="View detailed health status"
>
<i class="fas fa-expand" />
</button>
</div> </div>
<div v-if="health" class="space-y-2"> <div v-if="health" class="space-y-2">
@@ -124,9 +145,18 @@ function getStatusColor(status: string): string {
<!-- Memory Stats --> <!-- Memory Stats -->
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50"> <div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center justify-between mb-3">
<i class="fas fa-brain text-purple-400" /> <div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-white">Memory Contents</h3> <i class="fas fa-brain text-purple-400" />
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
</div>
<button
@click="showTopObservations = true"
class="text-xs text-amber-400 hover:text-amber-300 transition-colors"
title="View top observations"
>
<i class="fas fa-trophy" />
</button>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@@ -161,9 +191,18 @@ function getStatusColor(status: string): string {
<!-- Retrieval Stats --> <!-- Retrieval Stats -->
<div v-if="stats?.retrieval" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50"> <div v-if="stats?.retrieval" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center justify-between mb-3">
<i class="fas fa-search text-cyan-400" /> <div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3> <i class="fas fa-search text-cyan-400" />
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
</div>
<button
@click="showSearchAnalytics = true"
class="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
title="View detailed analytics"
>
<i class="fas fa-chart-line" />
</button>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@@ -261,5 +300,25 @@ function getStatusColor(status: string): string {
<i class="fas fa-search text-cyan-400" /> <i class="fas fa-search text-cyan-400" />
</div> </div>
</div> </div>
<!-- Search Analytics Modal -->
<SearchAnalytics
:show="showSearchAnalytics"
@close="showSearchAnalytics = false"
/>
<!-- System Health Details Modal -->
<SystemHealthDetails
:show="showHealthDetails"
@close="showHealthDetails = false"
/>
<!-- Top Observations Modal -->
<TopObservations
:show="showTopObservations"
:current-project="currentProject"
@close="showTopObservations = false"
@navigate-to-observation="$emit('update:project', null)"
/>
</aside> </aside>
</template> </template>
+249
View File
@@ -0,0 +1,249 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { fetchSystemHealth, type SystemHealth } from '@/utils/api'
import Card from './Card.vue'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const health = ref<SystemHealth | null>(null)
const loadData = async () => {
if (!props.show) return
loading.value = true
error.value = null
try {
health.value = await fetchSystemHealth()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load system health'
} finally {
loading.value = false
}
}
// Load on mount and when show changes
onMounted(() => {
if (props.show) loadData()
})
watch(() => props.show, (newVal) => {
if (newVal) loadData()
})
// Status helpers
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy': return 'fa-circle-check'
case 'degraded': return 'fa-triangle-exclamation'
case 'unhealthy': return 'fa-circle-xmark'
default: return 'fa-circle-question'
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy': return 'text-green-400'
case 'degraded': return 'text-amber-400'
case 'unhealthy': return 'text-red-400'
default: return 'text-slate-400'
}
}
const getStatusBgColor = (status: string) => {
switch (status) {
case 'healthy': return 'bg-green-500/20 border-green-500/30'
case 'degraded': return 'bg-amber-500/20 border-amber-500/30'
case 'unhealthy': return 'bg-red-500/20 border-red-500/30'
default: return 'bg-slate-500/20 border-slate-500/30'
}
}
const getLatencyColor = (ms: number | undefined) => {
if (!ms) return 'text-slate-400'
if (ms < 10) return 'text-green-400'
if (ms < 50) return 'text-amber-400'
return 'text-red-400'
}
// Count healthy/degraded/unhealthy components
const componentCounts = computed(() => {
if (!health.value) return { healthy: 0, degraded: 0, unhealthy: 0 }
const counts = { healthy: 0, degraded: 0, unhealthy: 0 }
for (const c of health.value.components) {
if (c.status === 'healthy') counts.healthy++
else if (c.status === 'degraded') counts.degraded++
else if (c.status === 'unhealthy') counts.unhealthy++
}
return counts
})
</script>
<template>
<!-- Modal Backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal Content -->
<div class="relative w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto">
<Card
gradient="bg-gradient-to-br from-emerald-500/10 to-green-500/5"
border-class="border-emerald-500/30"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-heartbeat text-emerald-400" />
<h3 class="text-lg font-semibold text-emerald-100">System Health</h3>
</div>
<button
@click="emit('close')"
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-2xl text-emerald-400" />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="health" class="space-y-5">
<!-- Overall Status -->
<div
class="p-4 rounded-lg border"
:class="getStatusBgColor(health.status)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<i
class="fas text-3xl"
:class="[getStatusIcon(health.status), getStatusColor(health.status)]"
/>
<div>
<div class="text-lg font-semibold capitalize" :class="getStatusColor(health.status)">
{{ health.status }}
</div>
<div class="text-xs text-slate-400">Overall System Status</div>
</div>
</div>
<div class="text-right">
<div class="text-sm text-slate-300 font-mono">{{ health.version }}</div>
<div class="text-xs text-slate-500">Version</div>
</div>
</div>
</div>
<!-- Component Status Summary -->
<div class="grid grid-cols-3 gap-3 text-center">
<div class="p-3 bg-slate-800/50 rounded-lg">
<div class="text-xl font-bold text-green-400">{{ componentCounts.healthy }}</div>
<div class="text-xs text-slate-500">Healthy</div>
</div>
<div class="p-3 bg-slate-800/50 rounded-lg">
<div class="text-xl font-bold text-amber-400">{{ componentCounts.degraded }}</div>
<div class="text-xs text-slate-500">Degraded</div>
</div>
<div class="p-3 bg-slate-800/50 rounded-lg">
<div class="text-xl font-bold text-red-400">{{ componentCounts.unhealthy }}</div>
<div class="text-xs text-slate-500">Unhealthy</div>
</div>
</div>
<!-- Components List -->
<div class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Components</div>
<div class="space-y-2">
<div
v-for="component in health.components"
:key="component.name"
class="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg"
>
<!-- Status Icon -->
<i
class="fas w-5 text-center"
:class="[getStatusIcon(component.status), getStatusColor(component.status)]"
/>
<!-- Name & Message -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-200">{{ component.name }}</div>
<div v-if="component.message" class="text-xs text-slate-500 truncate" :title="component.message">
{{ component.message }}
</div>
</div>
<!-- Latency -->
<div v-if="component.latency_ms !== undefined" class="text-right">
<span class="font-mono text-sm" :class="getLatencyColor(component.latency_ms)">
{{ component.latency_ms.toFixed(1) }}ms
</span>
</div>
<!-- Status Badge -->
<span
class="text-xs font-medium capitalize px-2 py-0.5 rounded"
:class="[getStatusColor(component.status), getStatusBgColor(component.status)]"
>
{{ component.status }}
</span>
</div>
</div>
</div>
<!-- Warnings -->
<div v-if="health.warnings && health.warnings.length > 0" class="space-y-3">
<div class="text-xs text-slate-500 uppercase tracking-wide">Warnings</div>
<div class="space-y-2">
<div
v-for="(warning, index) in health.warnings"
:key="index"
class="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg text-sm"
>
<i class="fas fa-exclamation-triangle text-amber-400 mt-0.5" />
<span class="text-amber-200">{{ warning }}</span>
</div>
</div>
</div>
<!-- Refresh Button -->
<button
@click="loadData"
:disabled="loading"
class="w-full py-2 text-sm text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }" />
Refresh Health Status
</button>
</div>
</Card>
</div>
</div>
</Teleport>
</template>
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { fetchTopObservations, fetchMostRetrievedObservations } from '@/utils/api'
import type { Observation } from '@/types'
import Card from './Card.vue'
const props = defineProps<{
show: boolean
currentProject: string | null
}>()
const emit = defineEmits<{
close: []
navigateToObservation: [id: number]
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const topObservations = ref<Observation[]>([])
const mostRetrieved = ref<Observation[]>([])
const activeTab = ref<'top' | 'retrieved'>('top')
const loadData = async () => {
if (!props.show) return
loading.value = true
error.value = null
try {
const project = props.currentProject || undefined
const [topData, retrievedData] = await Promise.all([
fetchTopObservations(project, 15),
fetchMostRetrievedObservations(project, 15)
])
topObservations.value = topData
mostRetrieved.value = retrievedData
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load observations'
} finally {
loading.value = false
}
}
// Load on mount and when show changes
onMounted(() => {
if (props.show) loadData()
})
watch(() => props.show, (newVal) => {
if (newVal) loadData()
})
// Also reload when project changes
watch(() => props.currentProject, () => {
if (props.show) loadData()
})
// Type config for styling
const typeConfig: Record<string, { icon: string; colorClass: string; bgClass: string }> = {
discovery: { icon: 'fa-lightbulb', colorClass: 'text-amber-400', bgClass: 'bg-amber-500/20' },
bugfix: { icon: 'fa-bug', colorClass: 'text-red-400', bgClass: 'bg-red-500/20' },
change: { icon: 'fa-code-branch', colorClass: 'text-blue-400', bgClass: 'bg-blue-500/20' },
refactor: { icon: 'fa-wrench', colorClass: 'text-purple-400', bgClass: 'bg-purple-500/20' },
feature: { icon: 'fa-star', colorClass: 'text-green-400', bgClass: 'bg-green-500/20' },
pattern: { icon: 'fa-puzzle-piece', colorClass: 'text-cyan-400', bgClass: 'bg-cyan-500/20' },
architecture: { icon: 'fa-sitemap', colorClass: 'text-indigo-400', bgClass: 'bg-indigo-500/20' },
preference: { icon: 'fa-heart', colorClass: 'text-pink-400', bgClass: 'bg-pink-500/20' }
}
const getTypeConfig = (type: string) => {
return typeConfig[type] || { icon: 'fa-circle', colorClass: 'text-slate-400', bgClass: 'bg-slate-500/20' }
}
// Format score for display
const formatScore = (score: number) => {
return score.toFixed(2)
}
// Current observations based on active tab
const currentObservations = () => {
return activeTab.value === 'top' ? topObservations.value : mostRetrieved.value
}
</script>
<template>
<!-- Modal Backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal Content -->
<div class="relative w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<Card
gradient="bg-gradient-to-br from-amber-500/10 to-orange-500/5"
border-class="border-amber-500/30"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-trophy text-amber-400" />
<h3 class="text-lg font-semibold text-amber-100">Top Observations</h3>
</div>
<button
@click="emit('close')"
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
<!-- Tabs -->
<div class="flex gap-2 mb-4">
<button
@click="activeTab = 'top'"
:class="[
'flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors',
activeTab === 'top'
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
]"
>
<i class="fas fa-star mr-2" />
Highest Scored
</button>
<button
@click="activeTab = 'retrieved'"
:class="[
'flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors',
activeTab === 'retrieved'
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
]"
>
<i class="fas fa-search mr-2" />
Most Retrieved
</button>
</div>
<!-- Project Filter Indicator -->
<div v-if="currentProject" class="flex items-center gap-2 mb-4 text-xs text-slate-500">
<i class="fas fa-filter" />
<span>Filtered by:</span>
<span class="text-amber-600/80 font-mono">{{ currentProject.split('/').pop() }}</span>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-2xl text-amber-400" />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
</div>
<!-- Empty State -->
<div v-else-if="currentObservations().length === 0" class="text-center py-8">
<i class="fas fa-inbox text-2xl text-slate-500 mb-2" />
<p class="text-slate-400">No observations found</p>
</div>
<!-- Content -->
<div v-else class="space-y-2">
<div
v-for="(obs, index) in currentObservations()"
:key="obs.id"
@click="emit('navigateToObservation', obs.id); emit('close')"
class="flex items-center gap-3 p-3 bg-slate-800/30 hover:bg-slate-800/50 rounded-lg cursor-pointer transition-colors group"
>
<!-- Rank -->
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
:class="[
index < 3 ? 'bg-amber-500/30 text-amber-300' : 'bg-slate-700/50 text-slate-400'
]"
>
{{ index + 1 }}
</div>
<!-- Type Icon -->
<div
class="w-8 h-8 rounded-lg flex items-center justify-center"
:class="getTypeConfig(obs.type).bgClass"
>
<i
class="fas text-sm"
:class="[getTypeConfig(obs.type).icon, getTypeConfig(obs.type).colorClass]"
/>
</div>
<!-- Title & Meta -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-200 truncate group-hover:text-amber-200 transition-colors">
{{ obs.title || 'Untitled' }}
</div>
<div class="flex items-center gap-2 text-xs text-slate-500">
<span class="capitalize">{{ obs.type }}</span>
<span v-if="obs.project" class="text-amber-600/70 font-mono">{{ obs.project.split('/').pop() }}</span>
</div>
</div>
<!-- Score / Retrieval Count -->
<div class="text-right flex-shrink-0">
<div
v-if="activeTab === 'top'"
class="text-sm font-mono font-bold"
:class="obs.importance_score && obs.importance_score >= 1.5 ? 'text-green-400' : obs.importance_score && obs.importance_score >= 1 ? 'text-amber-400' : 'text-slate-400'"
>
{{ formatScore(obs.importance_score || 1) }}
</div>
<div
v-else
class="text-sm font-mono font-bold text-cyan-400"
>
{{ obs.retrieval_count || 0 }}×
</div>
<div class="text-xs text-slate-500">
{{ activeTab === 'top' ? 'score' : 'retrieved' }}
</div>
</div>
<!-- Arrow -->
<i class="fas fa-chevron-right text-slate-600 group-hover:text-slate-400 transition-colors" />
</div>
</div>
<!-- Refresh Button -->
<button
@click="loadData"
:disabled="loading"
class="w-full mt-4 py-2 text-sm text-amber-400 hover:text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }" />
Refresh
</button>
</Card>
</div>
</div>
</Teleport>
</template>
+116
View File
@@ -164,3 +164,119 @@ export async function fetchRelatedObservations(observationId: number, minConfide
export async function fetchRelationStats(signal?: AbortSignal): Promise<RelationStats> { export async function fetchRelationStats(signal?: AbortSignal): Promise<RelationStats> {
return fetchWithRetry<RelationStats>(`${API_BASE}/relations/stats`, { signal }) return fetchWithRetry<RelationStats>(`${API_BASE}/relations/stats`, { signal })
} }
// Scoring API functions
export interface ScoreBreakdown {
observation: {
id: number
title: string
type: string
project: string
created_at: number
}
scoring: {
final_score: number
type_weight: number
recency_decay: number
core_score: number
feedback_contrib: number
concept_contrib: number
retrieval_contrib: number
age_days: number
}
explanation: {
type_impact: string
recency_impact: string
feedback_impact: string
concept_impact: string
retrieval_impact: string
}
}
export async function fetchObservationScore(observationId: number, signal?: AbortSignal): Promise<ScoreBreakdown> {
return fetchWithRetry<ScoreBreakdown>(`${API_BASE}/observations/${observationId}/score`, { signal })
}
export interface FeedbackStats {
total: number
positive: number
negative: number
neutral: number
avg_score: number
avg_retrieval: number
}
export interface TopObservation {
id: number
title: string
type: string
importance_score: number
retrieval_count?: number
}
export async function fetchScoringStats(project?: string, signal?: AbortSignal): Promise<FeedbackStats> {
const params = new URLSearchParams()
if (project) params.append('project', project)
const query = params.toString()
return fetchWithRetry<FeedbackStats>(`${API_BASE}/scoring/stats${query ? '?' + query : ''}`, { signal })
}
export async function fetchTopObservations(project?: string, limit: number = 10, signal?: AbortSignal): Promise<Observation[]> {
const params = new URLSearchParams({ limit: String(limit) })
if (project) params.append('project', project)
return fetchWithRetry<Observation[]>(`${API_BASE}/observations/top?${params}`, { signal })
}
export async function fetchMostRetrievedObservations(project?: string, limit: number = 10, signal?: AbortSignal): Promise<Observation[]> {
const params = new URLSearchParams({ limit: String(limit) })
if (project) params.append('project', project)
return fetchWithRetry<Observation[]>(`${API_BASE}/observations/most-retrieved?${params}`, { signal })
}
// Search Analytics API functions
export interface RecentQuery {
query: string
project?: string
type?: string
count: number
last_used: string
}
export interface SearchAnalytics {
total_searches: number
vector_searches: number
filter_searches: number
cache_hits: number
coalesced_requests: number
search_errors: number
avg_latency_ms: number
avg_vector_latency_ms: number
avg_filter_latency_ms: number
}
export async function fetchSearchAnalytics(signal?: AbortSignal): Promise<SearchAnalytics> {
return fetchWithRetry<SearchAnalytics>(`${API_BASE}/search/analytics`, { signal })
}
export async function fetchRecentSearches(limit: number = 20, signal?: AbortSignal): Promise<RecentQuery[]> {
return fetchWithRetry<RecentQuery[]>(`${API_BASE}/search/recent?limit=${limit}`, { signal })
}
// System health API
export interface ComponentHealth {
name: string
status: 'healthy' | 'degraded' | 'unhealthy'
message?: string
latency_ms?: number
}
export interface SystemHealth {
status: 'healthy' | 'degraded' | 'unhealthy'
version: string
components: ComponentHealth[]
warnings?: string[]
}
export async function fetchSystemHealth(signal?: AbortSignal): Promise<SystemHealth> {
return fetchWithRetry<SystemHealth>(`${API_BASE}/selfcheck`, { signal })
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/usetypes.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/relation.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/relationgraph.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"} {"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/usetypes.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/relation.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/relationgraph.vue","./src/components/scorebreakdown.vue","./src/components/searchanalytics.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/systemhealthdetails.vue","./src/components/timeline.vue","./src/components/topobservations.vue"],"version":"5.7.3"}