From 6a685a79c20ab8bf2937ad003abcbeae1a5c0fb9 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 16 Dec 2025 01:36:43 +0000 Subject: [PATCH] Additional abstractions for both sqlite and chroma. --- cmd/hooks/post-tool-use/main.go | 60 ++------- cmd/hooks/stop/main.go | 46 ++----- cmd/hooks/subagent-stop/main.go | 50 ++----- internal/db/sqlite/helpers.go | 160 ++++++++++++++++++++++ internal/db/sqlite/observation.go | 78 +---------- internal/db/sqlite/prompt.go | 54 ++------ internal/db/sqlite/summary.go | 96 +------------ internal/search/manager.go | 60 +++------ internal/vector/chroma/helpers.go | 175 ++++++++++++++++++++++++ internal/worker/handlers.go | 217 +++++++++++++++++++++++------- internal/worker/sdk/parser.go | 23 +++- internal/worker/sdk/processor.go | 67 +++------ internal/worker/service.go | 1 + pkg/hooks/response.go | 94 +++++++++++++ ui/package-lock.json | 4 +- ui/package.json | 2 +- ui/src/components/FilterTabs.vue | 9 +- ui/src/composables/useTypes.ts | 56 ++++++++ ui/src/types/observation.ts | 63 ++++++++- ui/tsconfig.tsbuildinfo | 2 +- 20 files changed, 834 insertions(+), 483 deletions(-) create mode 100644 internal/db/sqlite/helpers.go create mode 100644 internal/vector/chroma/helpers.go create mode 100644 ui/src/composables/useTypes.ts diff --git a/cmd/hooks/post-tool-use/main.go b/cmd/hooks/post-tool-use/main.go index 04e4a2d..01cc7e1 100644 --- a/cmd/hooks/post-tool-use/main.go +++ b/cmd/hooks/post-tool-use/main.go @@ -2,9 +2,7 @@ package main import ( - "encoding/json" "fmt" - "io" "os" "github.com/lukaszraczylo/claude-mnemonic/pkg/hooks" @@ -12,61 +10,29 @@ import ( // Input is the hook input from Claude Code. type Input struct { - SessionID string `json:"session_id"` - CWD string `json:"cwd"` - PermissionMode string `json:"permission_mode"` - HookEventName string `json:"hook_event_name"` - ToolName string `json:"tool_name"` - ToolInput interface{} `json:"tool_input"` - ToolResponse interface{} `json:"tool_response"` - ToolUseID string `json:"tool_use_id"` + hooks.BaseInput + ToolName string `json:"tool_name"` + ToolInput interface{} `json:"tool_input"` + ToolResponse interface{} `json:"tool_response"` + ToolUseID string `json:"tool_use_id"` } func main() { - // Skip if this is an internal call (from SDK processor) - if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" { - hooks.WriteResponse("PostToolUse", true) - return - } - - // Read input from stdin - inputData, err := io.ReadAll(os.Stdin) - if err != nil { - hooks.WriteError("PostToolUse", err) - os.Exit(1) - } - - var input Input - if err := json.Unmarshal(inputData, &input); err != nil { - hooks.WriteError("PostToolUse", err) - os.Exit(1) - } - - // Ensure worker is running - port, err := hooks.EnsureWorkerRunning() - if err != nil { - hooks.WriteError("PostToolUse", err) - os.Exit(1) - } + hooks.RunHook("PostToolUse", handlePostToolUse) +} +func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) { fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName) - // Generate project ID from CWD (same logic as user-prompt hook) - project := hooks.ProjectIDWithName(input.CWD) - // Send observation to worker - _, err = hooks.POST(port, "/api/sessions/observations", map[string]interface{}{ - "claudeSessionId": input.SessionID, - "project": project, + _, err := hooks.POST(ctx.Port, "/api/sessions/observations", map[string]interface{}{ + "claudeSessionId": ctx.SessionID, + "project": ctx.Project, "tool_name": input.ToolName, "tool_input": input.ToolInput, "tool_response": input.ToolResponse, - "cwd": input.CWD, + "cwd": ctx.CWD, }) - if err != nil { - hooks.WriteError("PostToolUse", err) - os.Exit(1) - } - hooks.WriteResponse("PostToolUse", true) + return "", err } diff --git a/cmd/hooks/stop/main.go b/cmd/hooks/stop/main.go index c821c2d..87f2d8b 100644 --- a/cmd/hooks/stop/main.go +++ b/cmd/hooks/stop/main.go @@ -5,7 +5,6 @@ import ( "bufio" "encoding/json" "fmt" - "io" "os" "strings" @@ -14,10 +13,7 @@ import ( // Input is the hook input from Claude Code. type Input struct { - SessionID string `json:"session_id"` - CWD string `json:"cwd"` - PermissionMode string `json:"permission_mode"` - HookEventName string `json:"hook_event_name"` + hooks.BaseInput StopHookActive bool `json:"stop_hook_active"` TranscriptPath string `json:"transcript_path"` } @@ -98,44 +94,20 @@ func parseTranscript(path string) (lastUser, lastAssistant string) { } func main() { - // Skip if this is an internal call (from SDK processor) - if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" { - hooks.WriteResponse("Stop", true) - return - } - - // Read input from stdin - inputData, err := io.ReadAll(os.Stdin) - if err != nil { - hooks.WriteError("Stop", err) - os.Exit(1) - } - - var input Input - if err := json.Unmarshal(inputData, &input); err != nil { - hooks.WriteError("Stop", err) - os.Exit(1) - } - - // Ensure worker is running - port, err := hooks.EnsureWorkerRunning() - if err != nil { - hooks.WriteError("Stop", err) - os.Exit(1) - } + hooks.RunHook("Stop", handleStop) +} +func handleStop(ctx *hooks.HookContext, input *Input) (string, error) { // Find session - result, err := hooks.GET(port, fmt.Sprintf("/api/sessions?claudeSessionId=%s", input.SessionID)) + result, err := hooks.GET(ctx.Port, fmt.Sprintf("/api/sessions?claudeSessionId=%s", ctx.SessionID)) if err != nil || result == nil { // Session might not exist, that's OK - hooks.WriteResponse("Stop", true) - return + return "", nil } sessionID, ok := result["id"].(float64) if !ok { - hooks.WriteResponse("Stop", true) - return + return "", nil } // Parse transcript to get last messages for summary context @@ -147,7 +119,7 @@ func main() { fmt.Fprintf(os.Stderr, "[stop] Requesting summary for session %d (transcript: %v)\n", int64(sessionID), input.TranscriptPath != "") // Request summary with message context from transcript - _, err = hooks.POST(port, fmt.Sprintf("/sessions/%d/summarize", int64(sessionID)), map[string]interface{}{ + _, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/summarize", int64(sessionID)), map[string]interface{}{ "lastUserMessage": lastUser, "lastAssistantMessage": lastAssistant, }) @@ -155,5 +127,5 @@ func main() { fmt.Fprintf(os.Stderr, "[stop] Warning: summary request failed: %v\n", err) } - hooks.WriteResponse("Stop", true) + return "", nil } diff --git a/cmd/hooks/subagent-stop/main.go b/cmd/hooks/subagent-stop/main.go index 2d54081..d437030 100644 --- a/cmd/hooks/subagent-stop/main.go +++ b/cmd/hooks/subagent-stop/main.go @@ -3,9 +3,7 @@ package main import ( - "encoding/json" "fmt" - "io" "os" "github.com/lukaszraczylo/claude-mnemonic/pkg/hooks" @@ -13,55 +11,27 @@ import ( // Input is the hook input from Claude Code. type Input struct { - SessionID string `json:"session_id"` - CWD string `json:"cwd"` - PermissionMode string `json:"permission_mode"` - HookEventName string `json:"hook_event_name"` - StopHookActive bool `json:"stop_hook_active"` + hooks.BaseInput + StopHookActive bool `json:"stop_hook_active"` } func main() { - // Skip if this is an internal call (from SDK processor) - if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" { - hooks.WriteResponse("SubagentStop", true) - return - } + hooks.RunHook("SubagentStop", handleSubagentStop) +} - // Read input from stdin - inputData, err := io.ReadAll(os.Stdin) - if err != nil { - hooks.WriteError("SubagentStop", err) - os.Exit(1) - } - - var input Input - if err := json.Unmarshal(inputData, &input); err != nil { - hooks.WriteError("SubagentStop", err) - os.Exit(1) - } - - // Ensure worker is running - port, err := hooks.EnsureWorkerRunning() - if err != nil { - hooks.WriteError("SubagentStop", err) - os.Exit(1) - } - - // Generate unique project ID from CWD - project := hooks.ProjectIDWithName(input.CWD) - - fmt.Fprintf(os.Stderr, "[subagent-stop] Subagent completed in project %s\n", project) +func handleSubagentStop(ctx *hooks.HookContext, input *Input) (string, error) { + fmt.Fprintf(os.Stderr, "[subagent-stop] Subagent completed in project %s\n", ctx.Project) // Notify worker that a subagent completed // This can trigger processing of any queued observations from the subagent - _, err = hooks.POST(port, "/api/sessions/subagent-complete", map[string]interface{}{ - "claudeSessionId": input.SessionID, - "project": project, + _, err := hooks.POST(ctx.Port, "/api/sessions/subagent-complete", map[string]interface{}{ + "claudeSessionId": ctx.SessionID, + "project": ctx.Project, }) if err != nil { // Non-fatal - just log warning fmt.Fprintf(os.Stderr, "[subagent-stop] Warning: failed to notify worker: %v\n", err) } - hooks.WriteResponse("SubagentStop", true) + return "", nil } diff --git a/internal/db/sqlite/helpers.go b/internal/db/sqlite/helpers.go new file mode 100644 index 0000000..b4d752c --- /dev/null +++ b/internal/db/sqlite/helpers.go @@ -0,0 +1,160 @@ +// Package sqlite provides SQLite database operations for claude-mnemonic. +package sqlite + +import ( + "context" + "database/sql" + "net/http" + "strconv" + "time" + + "github.com/lukaszraczylo/claude-mnemonic/pkg/models" +) + +// EnsureSessionExists creates a session if it doesn't exist. +// This is shared between ObservationStore and SummaryStore to avoid duplication. +func EnsureSessionExists(ctx context.Context, store *Store, sdkSessionID, project string) error { + const checkQuery = `SELECT id FROM sdk_sessions WHERE sdk_session_id = ?` + var id int64 + err := store.QueryRowContext(ctx, checkQuery, sdkSessionID).Scan(&id) + if err == nil { + return nil // Session exists + } + if err != sql.ErrNoRows { + return err + } + + // Auto-create session + now := time.Now() + const insertQuery = ` + INSERT INTO sdk_sessions + (claude_session_id, sdk_session_id, project, started_at, started_at_epoch, status) + VALUES (?, ?, ?, ?, ?, 'active') + ` + _, err = store.ExecContext(ctx, insertQuery, + sdkSessionID, sdkSessionID, project, + now.Format(time.RFC3339), now.UnixMilli(), + ) + return err +} + +// nullString converts a string to sql.NullString. +func nullString(s string) sql.NullString { + return sql.NullString{String: s, Valid: s != ""} +} + +// nullInt converts an int to sql.NullInt64. +func nullInt(i int) sql.NullInt64 { + return sql.NullInt64{Int64: int64(i), Valid: i > 0} +} + +// repeatPlaceholders generates n comma-prefixed placeholders for SQL IN clauses. +// e.g., repeatPlaceholders(2) returns ", ?, ?" +func repeatPlaceholders(n int) string { + if n <= 0 { + return "" + } + result := "" + for i := 0; i < n; i++ { + result += ", ?" + } + return result +} + +// int64SliceToInterface converts []int64 to []interface{} for SQL queries. +func int64SliceToInterface(ids []int64) []interface{} { + args := make([]interface{}, len(ids)) + for i, id := range ids { + args[i] = id + } + return args +} + +// ParseLimitParam parses a limit query parameter with a default value. +func ParseLimitParam(r *http.Request, defaultLimit int) int { + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + return parsed + } + } + return defaultLimit +} + +// scanSummary scans a single summary from a row scanner. +func scanSummary(scanner interface{ Scan(...interface{}) error }) (*models.SessionSummary, error) { + var summary models.SessionSummary + if err := scanner.Scan( + &summary.ID, &summary.SDKSessionID, &summary.Project, + &summary.Request, &summary.Investigated, &summary.Learned, &summary.Completed, + &summary.NextSteps, &summary.Notes, &summary.PromptNumber, &summary.DiscoveryTokens, + &summary.CreatedAt, &summary.CreatedAtEpoch, + ); err != nil { + return nil, err + } + return &summary, nil +} + +// scanSummaryRows scans multiple summaries from rows. +func scanSummaryRows(rows *sql.Rows) ([]*models.SessionSummary, error) { + var summaries []*models.SessionSummary + for rows.Next() { + summary, err := scanSummary(rows) + if err != nil { + return nil, err + } + summaries = append(summaries, summary) + } + return summaries, rows.Err() +} + +// scanPromptWithSession scans a single prompt with session info from a row scanner. +func scanPromptWithSession(scanner interface{ Scan(...interface{}) error }) (*models.UserPromptWithSession, error) { + var prompt models.UserPromptWithSession + if err := scanner.Scan( + &prompt.ID, &prompt.ClaudeSessionID, &prompt.PromptNumber, &prompt.PromptText, + &prompt.MatchedObservations, &prompt.CreatedAt, &prompt.CreatedAtEpoch, + &prompt.Project, &prompt.SDKSessionID, + ); err != nil { + return nil, err + } + return &prompt, nil +} + +// scanPromptWithSessionRows scans multiple prompts with session info from rows. +func scanPromptWithSessionRows(rows *sql.Rows) ([]*models.UserPromptWithSession, error) { + var prompts []*models.UserPromptWithSession + for rows.Next() { + prompt, err := scanPromptWithSession(rows) + if err != nil { + return nil, err + } + prompts = append(prompts, prompt) + } + return prompts, rows.Err() +} + +// BuildGetByIDsQuery builds a query for fetching records by IDs with optional ordering and limit. +// Returns the query string and args slice. +func BuildGetByIDsQuery(baseQuery string, ids []int64, orderBy string, limit int) (string, []interface{}) { + // Build query with placeholders + // #nosec G202 -- query uses parameterized placeholders, not user input + query := baseQuery + ` WHERE id IN (?` + repeatPlaceholders(len(ids)-1) + `) + ORDER BY created_at_epoch ` + + if orderBy == "date_asc" { + query += "ASC" + } else { + query += "DESC" + } + + if limit > 0 { + query += " LIMIT ?" + } + + args := int64SliceToInterface(ids) + if limit > 0 { + args = append(args, limit) + } + + return query, args +} diff --git a/internal/db/sqlite/observation.go b/internal/db/sqlite/observation.go index 3b4eb32..c5ca9a4 100644 --- a/internal/db/sqlite/observation.go +++ b/internal/db/sqlite/observation.go @@ -91,28 +91,7 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p // ensureSessionExists creates a session if it doesn't exist. func (s *ObservationStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error { - const checkQuery = `SELECT id FROM sdk_sessions WHERE sdk_session_id = ?` - var id int64 - err := s.store.QueryRowContext(ctx, checkQuery, sdkSessionID).Scan(&id) - if err == nil { - return nil // Session exists - } - if err != sql.ErrNoRows { - return err - } - - // Auto-create session - now := time.Now() - const insertQuery = ` - INSERT INTO sdk_sessions - (claude_session_id, sdk_session_id, project, started_at, started_at_epoch, status) - VALUES (?, ?, ?, ?, ?, 'active') - ` - _, err = s.store.ExecContext(ctx, insertQuery, - sdkSessionID, sdkSessionID, project, - now.Format(time.RFC3339), now.UnixMilli(), - ) - return err + return EnsureSessionExists(ctx, s.store, sdkSessionID, project) } // GetObservationByID retrieves an observation by ID. @@ -159,10 +138,7 @@ func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64 } // Convert []int64 to []interface{} - args := make([]interface{}, len(ids)) - for i, id := range ids { - args[i] = id - } + args := int64SliceToInterface(ids) if limit > 0 { args = append(args, limit) } @@ -355,37 +331,6 @@ func extractKeywords(query string) []string { return keywords } -// ExistsSimilarObservation checks if an observation about the same files exists for a project. -// Used to prevent duplicate observations when re-reading the same files. -func (s *ObservationStore) ExistsSimilarObservation(ctx context.Context, project string, filesRead, filesModified []string) (bool, error) { - // If no files tracked, can't deduplicate - if len(filesRead) == 0 && len(filesModified) == 0 { - return false, nil - } - - // Check if any observation exists with the same primary file - // Use the first file as the key identifier - var primaryFile string - if len(filesRead) > 0 { - primaryFile = filesRead[0] - } else if len(filesModified) > 0 { - primaryFile = filesModified[0] - } - - const query = ` - SELECT COUNT(*) FROM observations - WHERE project = ? AND (files_read LIKE ? OR files_modified LIKE ?) - ` - pattern := "%" + primaryFile + "%" - - var count int - err := s.store.QueryRowContext(ctx, query, project, pattern, pattern).Scan(&count) - if err != nil { - return false, err - } - return count > 0, nil -} - // DeleteObservations deletes multiple observations by ID. func (s *ObservationStore) DeleteObservations(ctx context.Context, ids []int64) (int64, error) { if len(ids) == 0 { @@ -497,21 +442,4 @@ func scanObservationRows(rows *sql.Rows) ([]*models.Observation, error) { return observations, rows.Err() } -func nullString(s string) sql.NullString { - return sql.NullString{String: s, Valid: s != ""} -} - -func nullInt(i int) sql.NullInt64 { - return sql.NullInt64{Int64: int64(i), Valid: i > 0} -} - -func repeatPlaceholders(n int) string { - if n <= 0 { - return "" - } - result := "" - for i := 0; i < n; i++ { - result += ", ?" - } - return result -} +// Note: nullString, nullInt, and repeatPlaceholders are in helpers.go diff --git a/internal/db/sqlite/prompt.go b/internal/db/sqlite/prompt.go index 4bde673..96e1350 100644 --- a/internal/db/sqlite/prompt.go +++ b/internal/db/sqlite/prompt.go @@ -128,9 +128,12 @@ func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy // #nosec G202 -- query uses parameterized placeholders, not user input query := ` SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text, - up.created_at, up.created_at_epoch, s.project, s.sdk_session_id + COALESCE(up.matched_observations, 0) as matched_observations, + up.created_at, up.created_at_epoch, + COALESCE(s.project, '') as project, + COALESCE(s.sdk_session_id, '') as sdk_session_id FROM user_prompts up - JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id + LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id WHERE up.id IN (?` + repeatPlaceholders(len(ids)-1) + `) ORDER BY up.created_at_epoch ` @@ -144,11 +147,7 @@ func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy query += " LIMIT ?" } - // Convert []int64 to []interface{} - args := make([]interface{}, len(ids)) - for i, id := range ids { - args[i] = id - } + args := int64SliceToInterface(ids) if limit > 0 { args = append(args, limit) } @@ -159,18 +158,7 @@ func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy } defer rows.Close() - var prompts []*models.UserPromptWithSession - for rows.Next() { - var prompt models.UserPromptWithSession - if err := rows.Scan( - &prompt.ID, &prompt.ClaudeSessionID, &prompt.PromptNumber, &prompt.PromptText, - &prompt.CreatedAt, &prompt.CreatedAtEpoch, &prompt.Project, &prompt.SDKSessionID, - ); err != nil { - return nil, err - } - prompts = append(prompts, &prompt) - } - return prompts, rows.Err() + return scanPromptWithSessionRows(rows) } // GetAllRecentUserPrompts retrieves recent user prompts across all sessions. @@ -193,19 +181,7 @@ func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([ } defer rows.Close() - var prompts []*models.UserPromptWithSession - for rows.Next() { - var prompt models.UserPromptWithSession - if err := rows.Scan( - &prompt.ID, &prompt.ClaudeSessionID, &prompt.PromptNumber, &prompt.PromptText, - &prompt.MatchedObservations, &prompt.CreatedAt, &prompt.CreatedAtEpoch, - &prompt.Project, &prompt.SDKSessionID, - ); err != nil { - return nil, err - } - prompts = append(prompts, &prompt) - } - return prompts, rows.Err() + return scanPromptWithSessionRows(rows) } // GetRecentUserPromptsByProject retrieves recent user prompts for a specific project. @@ -229,17 +205,5 @@ func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project } defer rows.Close() - var prompts []*models.UserPromptWithSession - for rows.Next() { - var prompt models.UserPromptWithSession - if err := rows.Scan( - &prompt.ID, &prompt.ClaudeSessionID, &prompt.PromptNumber, &prompt.PromptText, - &prompt.MatchedObservations, &prompt.CreatedAt, &prompt.CreatedAtEpoch, - &prompt.Project, &prompt.SDKSessionID, - ); err != nil { - return nil, err - } - prompts = append(prompts, &prompt) - } - return prompts, rows.Err() + return scanPromptWithSessionRows(rows) } diff --git a/internal/db/sqlite/summary.go b/internal/db/sqlite/summary.go index d647273..ae622f6 100644 --- a/internal/db/sqlite/summary.go +++ b/internal/db/sqlite/summary.go @@ -3,7 +3,6 @@ package sqlite import ( "context" - "database/sql" "time" "github.com/lukaszraczylo/claude-mnemonic/pkg/models" @@ -54,28 +53,7 @@ func (s *SummaryStore) StoreSummary(ctx context.Context, sdkSessionID, project s // ensureSessionExists creates a session if it doesn't exist. func (s *SummaryStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error { - const checkQuery = `SELECT id FROM sdk_sessions WHERE sdk_session_id = ?` - var id int64 - err := s.store.QueryRowContext(ctx, checkQuery, sdkSessionID).Scan(&id) - if err == nil { - return nil // Session exists - } - if err != sql.ErrNoRows { - return err - } - - // Auto-create session - now := time.Now() - const insertQuery = ` - INSERT INTO sdk_sessions - (claude_session_id, sdk_session_id, project, started_at, started_at_epoch, status) - VALUES (?, ?, ?, ?, ?, 'active') - ` - _, err = s.store.ExecContext(ctx, insertQuery, - sdkSessionID, sdkSessionID, project, - now.Format(time.RFC3339), now.UnixMilli(), - ) - return err + return EnsureSessionExists(ctx, s.store, sdkSessionID, project) } // GetSummariesByIDs retrieves summaries by a list of IDs. @@ -84,33 +62,12 @@ func (s *SummaryStore) GetSummariesByIDs(ctx context.Context, ids []int64, order return nil, nil } - // Build query with placeholders - // #nosec G202 -- query uses parameterized placeholders, not user input - query := ` + const baseQuery = ` SELECT id, sdk_session_id, project, request, investigated, learned, completed, next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch - FROM session_summaries - WHERE id IN (?` + repeatPlaceholders(len(ids)-1) + `) - ORDER BY created_at_epoch ` + FROM session_summaries` - if orderBy == "date_asc" { - query += "ASC" - } else { - query += "DESC" - } - - if limit > 0 { - query += " LIMIT ?" - } - - // Convert []int64 to []interface{} - args := make([]interface{}, len(ids)) - for i, id := range ids { - args[i] = id - } - if limit > 0 { - args = append(args, limit) - } + query, args := BuildGetByIDsQuery(baseQuery, ids, orderBy, limit) rows, err := s.store.db.QueryContext(ctx, query, args...) if err != nil { @@ -118,20 +75,7 @@ func (s *SummaryStore) GetSummariesByIDs(ctx context.Context, ids []int64, order } defer rows.Close() - var summaries []*models.SessionSummary - for rows.Next() { - var summary models.SessionSummary - if err := rows.Scan( - &summary.ID, &summary.SDKSessionID, &summary.Project, - &summary.Request, &summary.Investigated, &summary.Learned, &summary.Completed, - &summary.NextSteps, &summary.Notes, &summary.PromptNumber, &summary.DiscoveryTokens, - &summary.CreatedAt, &summary.CreatedAtEpoch, - ); err != nil { - return nil, err - } - summaries = append(summaries, &summary) - } - return summaries, rows.Err() + return scanSummaryRows(rows) } // GetRecentSummaries retrieves recent summaries for a project. @@ -151,20 +95,7 @@ func (s *SummaryStore) GetRecentSummaries(ctx context.Context, project string, l } defer rows.Close() - var summaries []*models.SessionSummary - for rows.Next() { - var summary models.SessionSummary - if err := rows.Scan( - &summary.ID, &summary.SDKSessionID, &summary.Project, - &summary.Request, &summary.Investigated, &summary.Learned, &summary.Completed, - &summary.NextSteps, &summary.Notes, &summary.PromptNumber, &summary.DiscoveryTokens, - &summary.CreatedAt, &summary.CreatedAtEpoch, - ); err != nil { - return nil, err - } - summaries = append(summaries, &summary) - } - return summaries, rows.Err() + return scanSummaryRows(rows) } // GetAllRecentSummaries retrieves recent summaries across all projects. @@ -183,18 +114,5 @@ func (s *SummaryStore) GetAllRecentSummaries(ctx context.Context, limit int) ([] } defer rows.Close() - var summaries []*models.SessionSummary - for rows.Next() { - var summary models.SessionSummary - if err := rows.Scan( - &summary.ID, &summary.SDKSessionID, &summary.Project, - &summary.Request, &summary.Investigated, &summary.Learned, &summary.Completed, - &summary.NextSteps, &summary.Notes, &summary.PromptNumber, &summary.DiscoveryTokens, - &summary.CreatedAt, &summary.CreatedAtEpoch, - ); err != nil { - return nil, err - } - summaries = append(summaries, &summary) - } - return summaries, rows.Err() + return scanSummaryRows(rows) } diff --git a/internal/search/manager.go b/internal/search/manager.go index d1eccfc..4d16a1a 100644 --- a/internal/search/manager.go +++ b/internal/search/manager.go @@ -94,18 +94,17 @@ func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*Unif // vectorSearch performs semantic search via ChromaDB. func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) { - // Build where filter - where := make(map[string]interface{}) - if params.Project != "" { - where["project"] = params.Project - } - if params.Type == "observations" { - where["doc_type"] = "observation" - } else if params.Type == "sessions" { - where["doc_type"] = "session_summary" - } else if params.Type == "prompts" { - where["doc_type"] = "user_prompt" + // Build where filter based on search type + var docType chroma.DocType + switch params.Type { + case "observations": + docType = chroma.DocTypeObservation + case "sessions": + docType = chroma.DocTypeSessionSummary + case "prompts": + docType = chroma.DocTypeUserPrompt } + where := chroma.BuildWhereFilter(docType, params.Project) // Query ChromaDB chromaResults, err := m.chromaClient.Query(ctx, params.Query, params.Limit*2, where) @@ -114,40 +113,11 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi return m.filterSearch(ctx, params) } - // Collect unique IDs by type - obsIDs := make([]int64, 0) - summaryIDs := make([]int64, 0) - promptIDs := make([]int64, 0) - seenObs := make(map[int64]bool) - seenSummary := make(map[int64]bool) - seenPrompt := make(map[int64]bool) - - for _, result := range chromaResults { - sqliteID, ok := result.Metadata["sqlite_id"].(float64) - if !ok { - continue - } - id := int64(sqliteID) - - docType, _ := result.Metadata["doc_type"].(string) - switch docType { - case "observation": - if !seenObs[id] { - seenObs[id] = true - obsIDs = append(obsIDs, id) - } - case "session_summary": - if !seenSummary[id] { - seenSummary[id] = true - summaryIDs = append(summaryIDs, id) - } - case "user_prompt": - if !seenPrompt[id] { - seenPrompt[id] = true - promptIDs = append(promptIDs, id) - } - } - } + // Extract IDs grouped by document type using shared helper + extracted := chroma.ExtractIDsByDocType(chromaResults) + obsIDs := extracted.ObservationIDs + summaryIDs := extracted.SummaryIDs + promptIDs := extracted.PromptIDs // Fetch full records from SQLite var results []SearchResult diff --git a/internal/vector/chroma/helpers.go b/internal/vector/chroma/helpers.go new file mode 100644 index 0000000..26287b9 --- /dev/null +++ b/internal/vector/chroma/helpers.go @@ -0,0 +1,175 @@ +// Package chroma provides ChromaDB vector database integration for claude-mnemonic. +package chroma + +// DocType represents the type of document stored in ChromaDB. +type DocType string + +const ( + DocTypeObservation DocType = "observation" + DocTypeSessionSummary DocType = "session_summary" + DocTypeUserPrompt DocType = "user_prompt" +) + +// ExtractedIDs contains SQLite IDs extracted from ChromaDB results, grouped by document type. +type ExtractedIDs struct { + ObservationIDs []int64 + SummaryIDs []int64 + PromptIDs []int64 +} + +// BuildWhereFilter creates a where filter map for ChromaDB queries. +// If docType is empty, no doc_type filter is added. +func BuildWhereFilter(docType DocType, project string) map[string]interface{} { + where := make(map[string]interface{}) + if docType != "" { + where["doc_type"] = string(docType) + } + if project != "" { + where["project"] = project + } + return where +} + +// ExtractIDsByDocType extracts SQLite IDs from ChromaDB query results, +// grouped by document type and deduplicated. +func ExtractIDsByDocType(results []QueryResult) *ExtractedIDs { + ids := &ExtractedIDs{} + seenObs := make(map[int64]bool) + seenSummary := make(map[int64]bool) + seenPrompt := make(map[int64]bool) + + for _, result := range results { + sqliteID, ok := result.Metadata["sqlite_id"].(float64) + if !ok { + continue + } + id := int64(sqliteID) + + docType, _ := result.Metadata["doc_type"].(string) + switch docType { + case string(DocTypeObservation): + if !seenObs[id] { + seenObs[id] = true + ids.ObservationIDs = append(ids.ObservationIDs, id) + } + case string(DocTypeSessionSummary): + if !seenSummary[id] { + seenSummary[id] = true + ids.SummaryIDs = append(ids.SummaryIDs, id) + } + case string(DocTypeUserPrompt): + if !seenPrompt[id] { + seenPrompt[id] = true + ids.PromptIDs = append(ids.PromptIDs, id) + } + } + } + + return ids +} + +// ExtractObservationIDs extracts observation SQLite IDs from ChromaDB query results, +// optionally filtering by project or including global scope. +// If project is empty, all observation IDs are returned. +// If project is set, only observations matching the project or with global scope are returned. +func ExtractObservationIDs(results []QueryResult, project string) []int64 { + var ids []int64 + seen := make(map[int64]bool) + + for _, result := range results { + sqliteID, ok := result.Metadata["sqlite_id"].(float64) + if !ok { + continue + } + id := int64(sqliteID) + + // Check document type + docType, _ := result.Metadata["doc_type"].(string) + if docType != string(DocTypeObservation) { + continue + } + + // Apply project/scope filter if project is specified + if project != "" { + proj, _ := result.Metadata["project"].(string) + scope, _ := result.Metadata["scope"].(string) + // Include if project matches OR scope is global + if proj != project && scope != "global" { + continue + } + } + + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + + return ids +} + +// ExtractSummaryIDs extracts session summary SQLite IDs from ChromaDB query results. +func ExtractSummaryIDs(results []QueryResult, project string) []int64 { + var ids []int64 + seen := make(map[int64]bool) + + for _, result := range results { + sqliteID, ok := result.Metadata["sqlite_id"].(float64) + if !ok { + continue + } + id := int64(sqliteID) + + docType, _ := result.Metadata["doc_type"].(string) + if docType != string(DocTypeSessionSummary) { + continue + } + + if project != "" { + proj, _ := result.Metadata["project"].(string) + if proj != project { + continue + } + } + + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + + return ids +} + +// ExtractPromptIDs extracts user prompt SQLite IDs from ChromaDB query results. +func ExtractPromptIDs(results []QueryResult, project string) []int64 { + var ids []int64 + seen := make(map[int64]bool) + + for _, result := range results { + sqliteID, ok := result.Metadata["sqlite_id"].(float64) + if !ok { + continue + } + id := int64(sqliteID) + + docType, _ := result.Metadata["doc_type"].(string) + if docType != string(DocTypeUserPrompt) { + continue + } + + if project != "" { + proj, _ := result.Metadata["project"].(string) + if proj != project { + continue + } + } + + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + + return ids +} diff --git a/internal/worker/handlers.go b/internal/worker/handlers.go index 9d195d2..4b847ef 100644 --- a/internal/worker/handlers.go +++ b/internal/worker/handlers.go @@ -9,7 +9,9 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite" "github.com/lukaszraczylo/claude-mnemonic/internal/privacy" + "github.com/lukaszraczylo/claude-mnemonic/internal/vector/chroma" "github.com/lukaszraczylo/claude-mnemonic/internal/worker/sdk" "github.com/lukaszraczylo/claude-mnemonic/internal/worker/session" "github.com/lukaszraczylo/claude-mnemonic/pkg/models" @@ -22,6 +24,53 @@ const ( DefaultObservationsLimit = 100 // DefaultSummariesLimit is the default number of summaries to return. +) + +// ObservationTypes is the canonical list of observation types. +// Used by both Go backend and served to frontend. +var ObservationTypes = []string{ + "bugfix", + "feature", + "refactor", + "discovery", + "decision", + "change", +} + +// ConceptTypes is the canonical list of valid concept types. +// Used by both Go backend and served to frontend. +var ConceptTypes = []string{ + // Semantic concepts + "how-it-works", + "why-it-exists", + "what-changed", + "problem-solution", + "gotcha", + "pattern", + "trade-off", + // Globalizable concepts (from models.GlobalizableConcepts) + "best-practice", + "anti-pattern", + "architecture", + "security", + "performance", + "testing", + "debugging", + "workflow", + "tooling", + // Additional useful concepts + "refactoring", + "api", + "database", + "configuration", + "error-handling", + "caching", + "logging", + "auth", + "validation", +} + +const ( DefaultSummariesLimit = 50 // DefaultPromptsLimit is the default number of prompts to return. @@ -401,25 +450,40 @@ func (s *Service) handleSummarize(w http.ResponseWriter, r *http.Request) { } // handleGetObservations returns recent observations. +// Supports optional query parameter for semantic search via ChromaDB. func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) { - limit := DefaultObservationsLimit - if l := r.URL.Query().Get("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { - limit = parsed - } - } - + limit := sqlite.ParseLimitParam(r, DefaultObservationsLimit) project := r.URL.Query().Get("project") + query := r.URL.Query().Get("query") var observations []*models.Observation var err error + var usedChroma bool - if project != "" { - // Filter by project - includes project-scoped and global observations - observations, err = s.observationStore.GetRecentObservations(r.Context(), project, limit) - } else { - // All projects - observations, err = s.observationStore.GetAllRecentObservations(r.Context(), limit) + // Use ChromaDB if query is provided and ChromaDB is available + if query != "" && s.chromaClient != nil && s.chromaClient.IsConnected() { + where := chroma.BuildWhereFilter(chroma.DocTypeObservation, "") + chromaResults, chromaErr := s.chromaClient.Query(r.Context(), query, limit*2, where) + if chromaErr == nil && len(chromaResults) > 0 { + obsIDs := chroma.ExtractObservationIDs(chromaResults, project) + if len(obsIDs) > 0 { + observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", limit) + if err == nil { + usedChroma = true + } + } + } + } + + // Fall back to SQLite if ChromaDB not used + if !usedChroma { + if project != "" { + // Filter by project - includes project-scoped and global observations + observations, err = s.observationStore.GetRecentObservations(r.Context(), project, limit) + } else { + // All projects + observations, err = s.observationStore.GetAllRecentObservations(r.Context(), limit) + } } if err != nil { @@ -435,23 +499,38 @@ func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) } // handleGetSummaries returns recent summaries. +// Supports optional query parameter for semantic search via ChromaDB. func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) { - limit := DefaultSummariesLimit - if l := r.URL.Query().Get("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { - limit = parsed - } - } - + limit := sqlite.ParseLimitParam(r, DefaultSummariesLimit) project := r.URL.Query().Get("project") + query := r.URL.Query().Get("query") var summaries []*models.SessionSummary var err error + var usedChroma bool - if project != "" { - summaries, err = s.summaryStore.GetRecentSummaries(r.Context(), project, limit) - } else { - summaries, err = s.summaryStore.GetAllRecentSummaries(r.Context(), limit) + // Use ChromaDB if query is provided and ChromaDB is available + if query != "" && s.chromaClient != nil && s.chromaClient.IsConnected() { + where := chroma.BuildWhereFilter(chroma.DocTypeSessionSummary, "") + chromaResults, chromaErr := s.chromaClient.Query(r.Context(), query, limit*2, where) + if chromaErr == nil && len(chromaResults) > 0 { + summaryIDs := chroma.ExtractSummaryIDs(chromaResults, project) + if len(summaryIDs) > 0 { + summaries, err = s.summaryStore.GetSummariesByIDs(r.Context(), summaryIDs, "date_desc", limit) + if err == nil { + usedChroma = true + } + } + } + } + + // Fall back to SQLite if ChromaDB not used + if !usedChroma { + if project != "" { + summaries, err = s.summaryStore.GetRecentSummaries(r.Context(), project, limit) + } else { + summaries, err = s.summaryStore.GetAllRecentSummaries(r.Context(), limit) + } } if err != nil { @@ -467,23 +546,38 @@ func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) { } // handleGetPrompts returns recent user prompts. +// Supports optional query parameter for semantic search via ChromaDB. func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) { - limit := DefaultPromptsLimit - if l := r.URL.Query().Get("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { - limit = parsed - } - } - + limit := sqlite.ParseLimitParam(r, DefaultPromptsLimit) project := r.URL.Query().Get("project") + query := r.URL.Query().Get("query") var prompts []*models.UserPromptWithSession var err error + var usedChroma bool - if project != "" { - prompts, err = s.promptStore.GetRecentUserPromptsByProject(r.Context(), project, limit) - } else { - prompts, err = s.promptStore.GetAllRecentUserPrompts(r.Context(), limit) + // Use ChromaDB if query is provided and ChromaDB is available + if query != "" && s.chromaClient != nil && s.chromaClient.IsConnected() { + where := chroma.BuildWhereFilter(chroma.DocTypeUserPrompt, "") + chromaResults, chromaErr := s.chromaClient.Query(r.Context(), query, limit*2, where) + if chromaErr == nil && len(chromaResults) > 0 { + promptIDs := chroma.ExtractPromptIDs(chromaResults, project) + if len(promptIDs) > 0 { + prompts, err = s.promptStore.GetPromptsByIDs(r.Context(), promptIDs, "date_desc", limit) + if err == nil { + usedChroma = true + } + } + } + } + + // Fall back to SQLite if ChromaDB not used + if !usedChroma { + if project != "" { + prompts, err = s.promptStore.GetRecentUserPromptsByProject(r.Context(), project, limit) + } else { + prompts, err = s.promptStore.GetAllRecentUserPrompts(r.Context(), limit) + } } if err != nil { @@ -509,6 +603,15 @@ func (s *Service) handleGetProjects(w http.ResponseWriter, r *http.Request) { 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. +func (s *Service) handleGetTypes(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]interface{}{ + "observation_types": ObservationTypes, + "concept_types": ConceptTypes, + }) +} + // handleGetStats returns worker statistics. func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) { retrievalStats := s.GetRetrievalStats() @@ -576,22 +679,42 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) { return } - limit := DefaultSearchLimit - if l := r.URL.Query().Get("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { - limit = parsed + limit := sqlite.ParseLimitParam(r, DefaultSearchLimit) + + var observations []*models.Observation + var err error + var usedChroma bool + + // Try ChromaDB vector search first if available + if s.chromaClient != nil && s.chromaClient.IsConnected() { + where := chroma.BuildWhereFilter(chroma.DocTypeObservation, "") + + chromaResults, chromaErr := s.chromaClient.Query(r.Context(), query, limit*2, where) + if chromaErr == nil && len(chromaResults) > 0 { + // Extract observation IDs with project/scope filtering using shared helper + obsIDs := chroma.ExtractObservationIDs(chromaResults, project) + + if len(obsIDs) > 0 { + // Fetch full observations from SQLite + observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", limit) + if err == nil { + usedChroma = true + } + } } } - // Search using FTS - 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) + // Fall back to FTS if ChromaDB not available or returned no results + if !usedChroma || len(observations) == 0 { + observations, err = s.observationStore.SearchObservationsFTS(r.Context(), query, project, limit) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + // 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 + } } } diff --git a/internal/worker/sdk/parser.go b/internal/worker/sdk/parser.go index e22b394..3d15054 100644 --- a/internal/worker/sdk/parser.go +++ b/internal/worker/sdk/parser.go @@ -27,8 +27,9 @@ var ( "decision": true, } - // Valid concepts (strict list - no custom tags allowed) + // Valid concepts - expanded list matching GlobalizableConcepts and common use cases validConcepts = map[string]bool{ + // Semantic concepts "how-it-works": true, "why-it-exists": true, "what-changed": true, @@ -36,6 +37,26 @@ var ( "gotcha": true, "pattern": true, "trade-off": true, + // Globalizable concepts (from models.GlobalizableConcepts) + "best-practice": true, + "anti-pattern": true, + "architecture": true, + "security": true, + "performance": true, + "testing": true, + "debugging": true, + "workflow": true, + "tooling": true, + // Additional useful concepts + "refactoring": true, + "api": true, + "database": true, + "configuration": true, + "error-handling": true, + "caching": true, + "logging": true, + "auth": true, + "validation": true, } ) diff --git a/internal/worker/sdk/processor.go b/internal/worker/sdk/processor.go index 4316e20..a0894b1 100644 --- a/internal/worker/sdk/processor.go +++ b/internal/worker/sdk/processor.go @@ -123,17 +123,9 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec log.Info().Str("tool", toolName).Msg("Processing tool execution with Claude CLI") - // Check if we already have observations for this file (skip if covered) - if filePath := extractFilePath(toolName, inputStr); filePath != "" { - exists, err := p.observationStore.ExistsSimilarObservation(ctx, project, []string{filePath}, nil) - if err == nil && exists { - log.Debug(). - Str("tool", toolName). - Str("file", filePath). - Msg("Skipping - file already has observations") - return nil - } - } + // Note: Removed the "file already has observations" check + // Each tool execution can produce unique insights even for the same file + // Similarity-based deduplication will handle true duplicates // Build the prompt exec := ToolExecution{ @@ -413,8 +405,9 @@ func shouldSkipTool(toolName string) bool { // shouldSkipTrivialOperation performs local pre-filtering to skip trivial operations // without making a Haiku API call. Returns true if the operation is too trivial to process. func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool { - // Skip if output is too small to be meaningful (less than 100 chars) - if len(outputStr) < 100 { + // Skip if output is too small to be meaningful (less than 50 chars) + // Reduced from 100 to capture more meaningful small operations + if len(outputStr) < 50 { return true } @@ -477,36 +470,6 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool { return false } -// extractFilePath extracts the file path from tool input for deduplication. -func extractFilePath(toolName, inputStr string) string { - if inputStr == "" { - return "" - } - - var input map[string]interface{} - if err := json.Unmarshal([]byte(inputStr), &input); err != nil { - return "" - } - - // Handle different tool input formats - switch toolName { - case "Read": - if fp, ok := input["file_path"].(string); ok { - return fp - } - case "Grep", "Search": - if path, ok := input["path"].(string); ok { - return path - } - case "Edit", "Write": - if fp, ok := input["file_path"].(string); ok { - return fp - } - } - - return "" -} - // toJSONString converts an interface to a JSON string. func toJSONString(v interface{}) string { if v == nil { @@ -767,12 +730,18 @@ func hasMeaningfulContent(assistantMsg string) bool { const systemPrompt = `You are a memory extraction agent for Claude Code sessions. Your job is to analyze tool executions and extract meaningful observations that would be useful for future sessions. GUIDELINES: -1. Only create observations for SIGNIFICANT learnings - not every tool call needs one -2. Focus on: decisions made, bugs fixed, patterns discovered, project structure learned -3. Skip trivial operations like simple file reads without insights +1. Create observations for any meaningful learnings - be generous, not restrictive +2. Focus on: decisions made, bugs fixed, patterns discovered, project structure, code changes, refactoring +3. Even small changes can be worth remembering if they reveal something about the codebase 4. Be concise but informative in your observations 5. Use appropriate type tags: decision, bugfix, feature, refactor, discovery, change +CONCEPT TAGS (use 1-3 of these): +- how-it-works, why-it-exists, what-changed, problem-solution, gotcha +- pattern, trade-off, best-practice, anti-pattern, architecture +- security, performance, testing, debugging, workflow, tooling +- refactoring, api, database, configuration, error-handling + OUTPUT FORMAT: When you find something worth remembering, output: @@ -794,5 +763,7 @@ When you find something worth remembering, output: -If the tool execution is not noteworthy, simply respond with: -` +If the tool execution is truly trivial (just a directory listing, empty result, etc.), respond with: + + +Prefer creating observations over skipping - memories are valuable for future context!` diff --git a/internal/worker/service.go b/internal/worker/service.go index c5b1ff4..b5a2a86 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -655,6 +655,7 @@ func (s *Service) setupRoutes() { r.Get("/api/projects", s.handleGetProjects) r.Get("/api/stats", s.handleGetStats) r.Get("/api/stats/retrieval", s.handleGetRetrievalStats) + r.Get("/api/types", s.handleGetTypes) // Context injection r.Get("/api/context/count", s.handleContextCount) diff --git a/pkg/hooks/response.go b/pkg/hooks/response.go index f0b283b..bd0eabf 100644 --- a/pkg/hooks/response.go +++ b/pkg/hooks/response.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "os" "path/filepath" ) @@ -49,3 +50,96 @@ func WriteError(hookName string, err error) { fmt.Fprintf(os.Stderr, "[%s] Error: %v\n", hookName, err) WriteResponse(hookName, false) } + +// BaseInput contains common fields shared by all hook inputs. +type BaseInput struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + PermissionMode string `json:"permission_mode"` + HookEventName string `json:"hook_event_name"` +} + +// HookContext provides common context for hook handlers. +type HookContext struct { + HookName string + Port int + Project string + SessionID string + CWD string + RawInput []byte +} + +// HookHandler is a function that handles hook-specific logic. +// It receives the context and returns an optional context string and error. +type HookHandler[T any] func(ctx *HookContext, input *T) (additionalContext string, err error) + +// RunHook executes a hook with common boilerplate handling. +// It handles: internal call skip, stdin reading, JSON unmarshaling, +// worker startup, and project ID generation. +func RunHook[T any](hookName string, handler HookHandler[T]) { + // Skip if this is an internal call (from SDK processor) + if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" { + WriteResponse(hookName, true) + return + } + + // Read input from stdin + inputData, err := io.ReadAll(os.Stdin) + if err != nil { + WriteError(hookName, err) + os.Exit(1) + } + + // Parse input + var input T + if err := json.Unmarshal(inputData, &input); err != nil { + WriteError(hookName, err) + os.Exit(1) + } + + // Ensure worker is running + port, err := EnsureWorkerRunning() + if err != nil { + WriteError(hookName, err) + os.Exit(1) + } + + // Extract base fields using interface assertion or reflection + var base BaseInput + _ = json.Unmarshal(inputData, &base) + + // Generate project ID from CWD + project := ProjectIDWithName(base.CWD) + + // Create context + ctx := &HookContext{ + HookName: hookName, + Port: port, + Project: project, + SessionID: base.SessionID, + CWD: base.CWD, + RawInput: inputData, + } + + // Run hook-specific handler + additionalContext, err := handler(ctx, &input) + if err != nil { + WriteError(hookName, err) + os.Exit(1) + } + + // Output response + if additionalContext != "" { + response := map[string]interface{}{ + "continue": true, + "hookSpecificOutput": map[string]interface{}{ + "hookEventName": hookName, + "additionalContext": additionalContext, + }, + } + _ = json.NewEncoder(os.Stdout).Encode(response) + os.Exit(0) + } + + WriteResponse(hookName, true) +} diff --git a/ui/package-lock.json b/ui/package-lock.json index c8d6b51..a9280d0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.6.1-8-ge52f328-dirty", + "version": "v0.6.1-9-g7e49113-dirty", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-mnemonic-dashboard", - "version": "v0.6.1-8-ge52f328-dirty", + "version": "v0.6.1-9-g7e49113-dirty", "dependencies": { "vue": "^3.5.13" }, diff --git a/ui/package.json b/ui/package.json index 72bc609..9150792 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.6.1-8-ge52f328-dirty", + "version": "v0.6.1-9-g7e49113-dirty", "private": true, "type": "module", "scripts": { diff --git a/ui/src/components/FilterTabs.vue b/ui/src/components/FilterTabs.vue index f462b3e..1aabf1f 100644 --- a/ui/src/components/FilterTabs.vue +++ b/ui/src/components/FilterTabs.vue @@ -1,6 +1,6 @@