Additional abstractions for both sqlite and chroma.

This commit is contained in:
2025-12-19 02:19:41 +00:00
parent c2904a31ad
commit a208d447dd
20 changed files with 834 additions and 483 deletions
+13 -47
View File
@@ -2,9 +2,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks" "github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
@@ -12,61 +10,29 @@ import (
// Input is the hook input from Claude Code. // Input is the hook input from Claude Code.
type Input struct { type Input struct {
SessionID string `json:"session_id"` hooks.BaseInput
CWD string `json:"cwd"` ToolName string `json:"tool_name"`
PermissionMode string `json:"permission_mode"` ToolInput interface{} `json:"tool_input"`
HookEventName string `json:"hook_event_name"` ToolResponse interface{} `json:"tool_response"`
ToolName string `json:"tool_name"` ToolUseID string `json:"tool_use_id"`
ToolInput interface{} `json:"tool_input"`
ToolResponse interface{} `json:"tool_response"`
ToolUseID string `json:"tool_use_id"`
} }
func main() { func main() {
// Skip if this is an internal call (from SDK processor) hooks.RunHook("PostToolUse", handlePostToolUse)
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)
}
func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) {
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName) 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 // Send observation to worker
_, err = hooks.POST(port, "/api/sessions/observations", map[string]interface{}{ _, err := hooks.POST(ctx.Port, "/api/sessions/observations", map[string]interface{}{
"claudeSessionId": input.SessionID, "claudeSessionId": ctx.SessionID,
"project": project, "project": ctx.Project,
"tool_name": input.ToolName, "tool_name": input.ToolName,
"tool_input": input.ToolInput, "tool_input": input.ToolInput,
"tool_response": input.ToolResponse, "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
} }
+9 -37
View File
@@ -5,7 +5,6 @@ import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
@@ -14,10 +13,7 @@ import (
// Input is the hook input from Claude Code. // Input is the hook input from Claude Code.
type Input struct { type Input struct {
SessionID string `json:"session_id"` hooks.BaseInput
CWD string `json:"cwd"`
PermissionMode string `json:"permission_mode"`
HookEventName string `json:"hook_event_name"`
StopHookActive bool `json:"stop_hook_active"` StopHookActive bool `json:"stop_hook_active"`
TranscriptPath string `json:"transcript_path"` TranscriptPath string `json:"transcript_path"`
} }
@@ -98,44 +94,20 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
} }
func main() { func main() {
// Skip if this is an internal call (from SDK processor) hooks.RunHook("Stop", handleStop)
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)
}
func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
// Find session // 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 { if err != nil || result == nil {
// Session might not exist, that's OK // Session might not exist, that's OK
hooks.WriteResponse("Stop", true) return "", nil
return
} }
sessionID, ok := result["id"].(float64) sessionID, ok := result["id"].(float64)
if !ok { if !ok {
hooks.WriteResponse("Stop", true) return "", nil
return
} }
// Parse transcript to get last messages for summary context // 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 != "") fmt.Fprintf(os.Stderr, "[stop] Requesting summary for session %d (transcript: %v)\n", int64(sessionID), input.TranscriptPath != "")
// Request summary with message context from transcript // 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, "lastUserMessage": lastUser,
"lastAssistantMessage": lastAssistant, "lastAssistantMessage": lastAssistant,
}) })
@@ -155,5 +127,5 @@ func main() {
fmt.Fprintf(os.Stderr, "[stop] Warning: summary request failed: %v\n", err) fmt.Fprintf(os.Stderr, "[stop] Warning: summary request failed: %v\n", err)
} }
hooks.WriteResponse("Stop", true) return "", nil
} }
+10 -40
View File
@@ -3,9 +3,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks" "github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
@@ -13,55 +11,27 @@ import (
// Input is the hook input from Claude Code. // Input is the hook input from Claude Code.
type Input struct { type Input struct {
SessionID string `json:"session_id"` hooks.BaseInput
CWD string `json:"cwd"` StopHookActive bool `json:"stop_hook_active"`
PermissionMode string `json:"permission_mode"`
HookEventName string `json:"hook_event_name"`
StopHookActive bool `json:"stop_hook_active"`
} }
func main() { func main() {
// Skip if this is an internal call (from SDK processor) hooks.RunHook("SubagentStop", handleSubagentStop)
if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" { }
hooks.WriteResponse("SubagentStop", true)
return
}
// Read input from stdin func handleSubagentStop(ctx *hooks.HookContext, input *Input) (string, error) {
inputData, err := io.ReadAll(os.Stdin) fmt.Fprintf(os.Stderr, "[subagent-stop] Subagent completed in project %s\n", ctx.Project)
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)
// Notify worker that a subagent completed // Notify worker that a subagent completed
// This can trigger processing of any queued observations from the subagent // This can trigger processing of any queued observations from the subagent
_, err = hooks.POST(port, "/api/sessions/subagent-complete", map[string]interface{}{ _, err := hooks.POST(ctx.Port, "/api/sessions/subagent-complete", map[string]interface{}{
"claudeSessionId": input.SessionID, "claudeSessionId": ctx.SessionID,
"project": project, "project": ctx.Project,
}) })
if err != nil { if err != nil {
// Non-fatal - just log warning // Non-fatal - just log warning
fmt.Fprintf(os.Stderr, "[subagent-stop] Warning: failed to notify worker: %v\n", err) fmt.Fprintf(os.Stderr, "[subagent-stop] Warning: failed to notify worker: %v\n", err)
} }
hooks.WriteResponse("SubagentStop", true) return "", nil
} }
+160
View File
@@ -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
}
+3 -75
View File
@@ -91,28 +91,7 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p
// ensureSessionExists creates a session if it doesn't exist. // ensureSessionExists creates a session if it doesn't exist.
func (s *ObservationStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error { func (s *ObservationStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error {
const checkQuery = `SELECT id FROM sdk_sessions WHERE sdk_session_id = ?` return EnsureSessionExists(ctx, s.store, sdkSessionID, project)
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
} }
// GetObservationByID retrieves an observation by ID. // GetObservationByID retrieves an observation by ID.
@@ -159,10 +138,7 @@ func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64
} }
// Convert []int64 to []interface{} // Convert []int64 to []interface{}
args := make([]interface{}, len(ids)) args := int64SliceToInterface(ids)
for i, id := range ids {
args[i] = id
}
if limit > 0 { if limit > 0 {
args = append(args, limit) args = append(args, limit)
} }
@@ -355,37 +331,6 @@ func extractKeywords(query string) []string {
return keywords 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. // DeleteObservations deletes multiple observations by ID.
func (s *ObservationStore) DeleteObservations(ctx context.Context, ids []int64) (int64, error) { func (s *ObservationStore) DeleteObservations(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 { if len(ids) == 0 {
@@ -497,21 +442,4 @@ func scanObservationRows(rows *sql.Rows) ([]*models.Observation, error) {
return observations, rows.Err() return observations, rows.Err()
} }
func nullString(s string) sql.NullString { // Note: nullString, nullInt, and repeatPlaceholders are in helpers.go
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
}
+9 -45
View File
@@ -128,9 +128,12 @@ func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy
// #nosec G202 -- query uses parameterized placeholders, not user input // #nosec G202 -- query uses parameterized placeholders, not user input
query := ` query := `
SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text, 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 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) + `) WHERE up.id IN (?` + repeatPlaceholders(len(ids)-1) + `)
ORDER BY up.created_at_epoch ` ORDER BY up.created_at_epoch `
@@ -144,11 +147,7 @@ func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy
query += " LIMIT ?" query += " LIMIT ?"
} }
// Convert []int64 to []interface{} args := int64SliceToInterface(ids)
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
if limit > 0 { if limit > 0 {
args = append(args, limit) args = append(args, limit)
} }
@@ -159,18 +158,7 @@ func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy
} }
defer rows.Close() defer rows.Close()
var prompts []*models.UserPromptWithSession return scanPromptWithSessionRows(rows)
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()
} }
// GetAllRecentUserPrompts retrieves recent user prompts across all sessions. // GetAllRecentUserPrompts retrieves recent user prompts across all sessions.
@@ -193,19 +181,7 @@ func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([
} }
defer rows.Close() defer rows.Close()
var prompts []*models.UserPromptWithSession return scanPromptWithSessionRows(rows)
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()
} }
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project. // GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
@@ -229,17 +205,5 @@ func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project
} }
defer rows.Close() defer rows.Close()
var prompts []*models.UserPromptWithSession return scanPromptWithSessionRows(rows)
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()
} }
+7 -89
View File
@@ -3,7 +3,6 @@ package sqlite
import ( import (
"context" "context"
"database/sql"
"time" "time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models" "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. // ensureSessionExists creates a session if it doesn't exist.
func (s *SummaryStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error { func (s *SummaryStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error {
const checkQuery = `SELECT id FROM sdk_sessions WHERE sdk_session_id = ?` return EnsureSessionExists(ctx, s.store, sdkSessionID, project)
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
} }
// GetSummariesByIDs retrieves summaries by a list of IDs. // 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 return nil, nil
} }
// Build query with placeholders const baseQuery = `
// #nosec G202 -- query uses parameterized placeholders, not user input
query := `
SELECT id, sdk_session_id, project, request, investigated, learned, completed, SELECT id, sdk_session_id, project, request, investigated, learned, completed,
next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch
FROM session_summaries FROM session_summaries`
WHERE id IN (?` + repeatPlaceholders(len(ids)-1) + `)
ORDER BY created_at_epoch `
if orderBy == "date_asc" { query, args := BuildGetByIDsQuery(baseQuery, ids, orderBy, limit)
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)
}
rows, err := s.store.db.QueryContext(ctx, query, args...) rows, err := s.store.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
@@ -118,20 +75,7 @@ func (s *SummaryStore) GetSummariesByIDs(ctx context.Context, ids []int64, order
} }
defer rows.Close() defer rows.Close()
var summaries []*models.SessionSummary return scanSummaryRows(rows)
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()
} }
// GetRecentSummaries retrieves recent summaries for a project. // GetRecentSummaries retrieves recent summaries for a project.
@@ -151,20 +95,7 @@ func (s *SummaryStore) GetRecentSummaries(ctx context.Context, project string, l
} }
defer rows.Close() defer rows.Close()
var summaries []*models.SessionSummary return scanSummaryRows(rows)
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()
} }
// GetAllRecentSummaries retrieves recent summaries across all projects. // GetAllRecentSummaries retrieves recent summaries across all projects.
@@ -183,18 +114,5 @@ func (s *SummaryStore) GetAllRecentSummaries(ctx context.Context, limit int) ([]
} }
defer rows.Close() defer rows.Close()
var summaries []*models.SessionSummary return scanSummaryRows(rows)
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()
} }
+15 -45
View File
@@ -94,18 +94,17 @@ func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*Unif
// vectorSearch performs semantic search via ChromaDB. // vectorSearch performs semantic search via ChromaDB.
func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) { func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
// Build where filter // Build where filter based on search type
where := make(map[string]interface{}) var docType chroma.DocType
if params.Project != "" { switch params.Type {
where["project"] = params.Project case "observations":
} docType = chroma.DocTypeObservation
if params.Type == "observations" { case "sessions":
where["doc_type"] = "observation" docType = chroma.DocTypeSessionSummary
} else if params.Type == "sessions" { case "prompts":
where["doc_type"] = "session_summary" docType = chroma.DocTypeUserPrompt
} else if params.Type == "prompts" {
where["doc_type"] = "user_prompt"
} }
where := chroma.BuildWhereFilter(docType, params.Project)
// Query ChromaDB // Query ChromaDB
chromaResults, err := m.chromaClient.Query(ctx, params.Query, params.Limit*2, where) 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) return m.filterSearch(ctx, params)
} }
// Collect unique IDs by type // Extract IDs grouped by document type using shared helper
obsIDs := make([]int64, 0) extracted := chroma.ExtractIDsByDocType(chromaResults)
summaryIDs := make([]int64, 0) obsIDs := extracted.ObservationIDs
promptIDs := make([]int64, 0) summaryIDs := extracted.SummaryIDs
seenObs := make(map[int64]bool) promptIDs := extracted.PromptIDs
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)
}
}
}
// Fetch full records from SQLite // Fetch full records from SQLite
var results []SearchResult var results []SearchResult
+175
View File
@@ -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
}
+170 -47
View File
@@ -9,7 +9,9 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "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/privacy"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/chroma"
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/sdk" "github.com/lukaszraczylo/claude-mnemonic/internal/worker/sdk"
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/session" "github.com/lukaszraczylo/claude-mnemonic/internal/worker/session"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models" "github.com/lukaszraczylo/claude-mnemonic/pkg/models"
@@ -22,6 +24,53 @@ const (
DefaultObservationsLimit = 100 DefaultObservationsLimit = 100
// DefaultSummariesLimit is the default number of summaries to return. // 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 DefaultSummariesLimit = 50
// DefaultPromptsLimit is the default number of prompts to return. // 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. // handleGetObservations returns recent observations.
// Supports optional query parameter for semantic search via ChromaDB.
func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) { func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) {
limit := DefaultObservationsLimit limit := sqlite.ParseLimitParam(r, DefaultObservationsLimit)
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
project := r.URL.Query().Get("project") project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
var observations []*models.Observation var observations []*models.Observation
var err error var err error
var usedChroma bool
if project != "" { // Use ChromaDB if query is provided and ChromaDB is available
// Filter by project - includes project-scoped and global observations if query != "" && s.chromaClient != nil && s.chromaClient.IsConnected() {
observations, err = s.observationStore.GetRecentObservations(r.Context(), project, limit) where := chroma.BuildWhereFilter(chroma.DocTypeObservation, "")
} else { chromaResults, chromaErr := s.chromaClient.Query(r.Context(), query, limit*2, where)
// All projects if chromaErr == nil && len(chromaResults) > 0 {
observations, err = s.observationStore.GetAllRecentObservations(r.Context(), limit) 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 { if err != nil {
@@ -435,23 +499,38 @@ func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request)
} }
// handleGetSummaries returns recent summaries. // handleGetSummaries returns recent summaries.
// Supports optional query parameter for semantic search via ChromaDB.
func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) { func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
limit := DefaultSummariesLimit limit := sqlite.ParseLimitParam(r, DefaultSummariesLimit)
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
project := r.URL.Query().Get("project") project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
var summaries []*models.SessionSummary var summaries []*models.SessionSummary
var err error var err error
var usedChroma bool
if project != "" { // Use ChromaDB if query is provided and ChromaDB is available
summaries, err = s.summaryStore.GetRecentSummaries(r.Context(), project, limit) if query != "" && s.chromaClient != nil && s.chromaClient.IsConnected() {
} else { where := chroma.BuildWhereFilter(chroma.DocTypeSessionSummary, "")
summaries, err = s.summaryStore.GetAllRecentSummaries(r.Context(), limit) 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 { if err != nil {
@@ -467,23 +546,38 @@ func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
} }
// handleGetPrompts returns recent user prompts. // handleGetPrompts returns recent user prompts.
// Supports optional query parameter for semantic search via ChromaDB.
func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) { func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
limit := DefaultPromptsLimit limit := sqlite.ParseLimitParam(r, DefaultPromptsLimit)
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
project := r.URL.Query().Get("project") project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
var prompts []*models.UserPromptWithSession var prompts []*models.UserPromptWithSession
var err error var err error
var usedChroma bool
if project != "" { // Use ChromaDB if query is provided and ChromaDB is available
prompts, err = s.promptStore.GetRecentUserPromptsByProject(r.Context(), project, limit) if query != "" && s.chromaClient != nil && s.chromaClient.IsConnected() {
} else { where := chroma.BuildWhereFilter(chroma.DocTypeUserPrompt, "")
prompts, err = s.promptStore.GetAllRecentUserPrompts(r.Context(), limit) 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 { if err != nil {
@@ -509,6 +603,15 @@ func (s *Service) handleGetProjects(w http.ResponseWriter, r *http.Request) {
writeJSON(w, projects) 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. // handleGetStats returns worker statistics.
func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) { func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
retrievalStats := s.GetRetrievalStats() retrievalStats := s.GetRetrievalStats()
@@ -576,22 +679,42 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
return return
} }
limit := DefaultSearchLimit limit := sqlite.ParseLimitParam(r, DefaultSearchLimit)
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { var observations []*models.Observation
limit = parsed 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 // Fall back to FTS if ChromaDB not available or returned no results
observations, err := s.observationStore.SearchObservationsFTS(r.Context(), query, project, limit) if !usedChroma || len(observations) == 0 {
if err != nil { observations, err = s.observationStore.SearchObservationsFTS(r.Context(), query, project, limit)
// 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) // FTS might fail if query has special chars, try without
return 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
}
} }
} }
+22 -1
View File
@@ -27,8 +27,9 @@ var (
"decision": true, "decision": true,
} }
// Valid concepts (strict list - no custom tags allowed) // Valid concepts - expanded list matching GlobalizableConcepts and common use cases
validConcepts = map[string]bool{ validConcepts = map[string]bool{
// Semantic concepts
"how-it-works": true, "how-it-works": true,
"why-it-exists": true, "why-it-exists": true,
"what-changed": true, "what-changed": true,
@@ -36,6 +37,26 @@ var (
"gotcha": true, "gotcha": true,
"pattern": true, "pattern": true,
"trade-off": 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,
} }
) )
+19 -48
View File
@@ -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") log.Info().Str("tool", toolName).Msg("Processing tool execution with Claude CLI")
// Check if we already have observations for this file (skip if covered) // Note: Removed the "file already has observations" check
if filePath := extractFilePath(toolName, inputStr); filePath != "" { // Each tool execution can produce unique insights even for the same file
exists, err := p.observationStore.ExistsSimilarObservation(ctx, project, []string{filePath}, nil) // Similarity-based deduplication will handle true duplicates
if err == nil && exists {
log.Debug().
Str("tool", toolName).
Str("file", filePath).
Msg("Skipping - file already has observations")
return nil
}
}
// Build the prompt // Build the prompt
exec := ToolExecution{ exec := ToolExecution{
@@ -413,8 +405,9 @@ func shouldSkipTool(toolName string) bool {
// shouldSkipTrivialOperation performs local pre-filtering to skip trivial operations // 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. // without making a Haiku API call. Returns true if the operation is too trivial to process.
func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool { func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
// Skip if output is too small to be meaningful (less than 100 chars) // Skip if output is too small to be meaningful (less than 50 chars)
if len(outputStr) < 100 { // Reduced from 100 to capture more meaningful small operations
if len(outputStr) < 50 {
return true return true
} }
@@ -477,36 +470,6 @@ func shouldSkipTrivialOperation(toolName, inputStr, outputStr string) bool {
return false 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. // toJSONString converts an interface to a JSON string.
func toJSONString(v interface{}) string { func toJSONString(v interface{}) string {
if v == nil { 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. 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: GUIDELINES:
1. Only create observations for SIGNIFICANT learnings - not every tool call needs one 1. Create observations for any meaningful learnings - be generous, not restrictive
2. Focus on: decisions made, bugs fixed, patterns discovered, project structure learned 2. Focus on: decisions made, bugs fixed, patterns discovered, project structure, code changes, refactoring
3. Skip trivial operations like simple file reads without insights 3. Even small changes can be worth remembering if they reveal something about the codebase
4. Be concise but informative in your observations 4. Be concise but informative in your observations
5. Use appropriate type tags: decision, bugfix, feature, refactor, discovery, change 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: OUTPUT FORMAT:
When you find something worth remembering, output: When you find something worth remembering, output:
<observation> <observation>
@@ -794,5 +763,7 @@ When you find something worth remembering, output:
</files_modified> </files_modified>
</observation> </observation>
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:
<skip reason="not significant"/>` <skip reason="trivial"/>
Prefer creating observations over skipping - memories are valuable for future context!`
+1
View File
@@ -655,6 +655,7 @@ func (s *Service) setupRoutes() {
r.Get("/api/projects", s.handleGetProjects) r.Get("/api/projects", s.handleGetProjects)
r.Get("/api/stats", s.handleGetStats) r.Get("/api/stats", s.handleGetStats)
r.Get("/api/stats/retrieval", s.handleGetRetrievalStats) r.Get("/api/stats/retrieval", s.handleGetRetrievalStats)
r.Get("/api/types", s.handleGetTypes)
// Context injection // Context injection
r.Get("/api/context/count", s.handleContextCount) r.Get("/api/context/count", s.handleContextCount)
+94
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
) )
@@ -49,3 +50,96 @@ func WriteError(hookName string, err error) {
fmt.Fprintf(os.Stderr, "[%s] Error: %v\n", hookName, err) fmt.Fprintf(os.Stderr, "[%s] Error: %v\n", hookName, err)
WriteResponse(hookName, false) 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)
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "claude-mnemonic-dashboard", "name": "claude-mnemonic-dashboard",
"version": "v0.6.1-8-ge52f328-dirty", "version": "v0.6.1-9-g7e49113-dirty",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "claude-mnemonic-dashboard", "name": "claude-mnemonic-dashboard",
"version": "v0.6.1-8-ge52f328-dirty", "version": "v0.6.1-9-g7e49113-dirty",
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "claude-mnemonic-dashboard", "name": "claude-mnemonic-dashboard",
"version": "v0.6.1-8-ge52f328-dirty", "version": "v0.6.1-9-g7e49113-dirty",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+6 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FilterType, ObservationType, ConceptType } from '@/types' import type { FilterType, ObservationType, ConceptType } from '@/types'
import { OBSERVATION_TYPES, CONCEPT_TYPES } from '@/types/observation' import { useTypes } from '@/composables/useTypes'
defineProps<{ defineProps<{
currentFilter: FilterType currentFilter: FilterType
@@ -16,6 +16,9 @@ const emit = defineEmits<{
'update:conceptFilter': [concept: ConceptType | null] 'update:conceptFilter': [concept: ConceptType | null]
}>() }>()
// Fetch types from API (cached)
const { observationTypes, conceptTypes } = useTypes()
const tabs: { key: FilterType; label: string; icon: string }[] = [ const tabs: { key: FilterType; label: string; icon: string }[] = [
{ key: 'all', label: 'All', icon: 'fa-layer-group' }, { key: 'all', label: 'All', icon: 'fa-layer-group' },
{ key: 'observations', label: 'Observations', icon: 'fa-brain' }, { key: 'observations', label: 'Observations', icon: 'fa-brain' },
@@ -63,7 +66,7 @@ const tabs: { key: FilterType; label: string; icon: string }[] = [
@change="emit('update:typeFilter', ($event.target as HTMLSelectElement).value as ObservationType || null)" @change="emit('update:typeFilter', ($event.target as HTMLSelectElement).value as ObservationType || null)"
> >
<option value="">All Types</option> <option value="">All Types</option>
<option v-for="type in OBSERVATION_TYPES" :key="type" :value="type"> <option v-for="type in observationTypes" :key="type" :value="type">
{{ type }} {{ type }}
</option> </option>
</select> </select>
@@ -78,7 +81,7 @@ const tabs: { key: FilterType; label: string; icon: string }[] = [
@change="emit('update:conceptFilter', ($event.target as HTMLSelectElement).value as ConceptType || null)" @change="emit('update:conceptFilter', ($event.target as HTMLSelectElement).value as ConceptType || null)"
> >
<option value="">All Concepts</option> <option value="">All Concepts</option>
<option v-for="concept in CONCEPT_TYPES" :key="concept" :value="concept"> <option v-for="concept in conceptTypes" :key="concept" :value="concept">
{{ concept }} {{ concept }}
</option> </option>
</select> </select>
+56
View File
@@ -0,0 +1,56 @@
import { ref, onMounted } from 'vue'
export interface TypesResponse {
observation_types: string[]
concept_types: string[]
}
// Default fallback types
const DEFAULT_OBSERVATION_TYPES = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change']
const DEFAULT_CONCEPT_TYPES = [
'gotcha', 'pattern', 'problem-solution', 'trade-off',
'how-it-works', 'why-it-exists', 'what-changed',
'best-practice', 'anti-pattern', 'architecture',
'security', 'performance', 'testing', 'debugging', 'workflow', 'tooling',
'refactoring', 'api', 'database', 'configuration', 'error-handling',
'caching', 'logging', 'auth', 'validation'
]
// Cached types data (shared across components)
const observationTypes = ref<string[]>(DEFAULT_OBSERVATION_TYPES)
const conceptTypes = ref<string[]>(DEFAULT_CONCEPT_TYPES)
const loaded = ref(false)
const loading = ref(false)
export function useTypes() {
const fetchTypes = async () => {
if (loaded.value || loading.value) return
loading.value = true
try {
const response = await fetch('/api/types')
if (!response.ok) throw new Error('Failed to fetch types')
const data: TypesResponse = await response.json()
observationTypes.value = data.observation_types
conceptTypes.value = data.concept_types
loaded.value = true
} catch (error) {
console.error('Failed to fetch types:', error)
// Keep defaults
loaded.value = true
} finally {
loading.value = false
}
}
// Fetch on first use
onMounted(fetchTypes)
return {
observationTypes,
conceptTypes,
loaded,
loading,
fetchTypes
}
}
+61 -2
View File
@@ -1,6 +1,15 @@
export type ObservationType = 'bugfix' | 'feature' | 'refactor' | 'discovery' | 'decision' | 'change' export type ObservationType = 'bugfix' | 'feature' | 'refactor' | 'discovery' | 'decision' | 'change'
export type ObservationScope = 'project' | 'global' export type ObservationScope = 'project' | 'global'
export type ConceptType = 'gotcha' | 'pattern' | 'problem-solution' | 'trade-off' | 'how-it-works' | 'why-it-exists' | 'what-changed' export type ConceptType =
// Semantic concepts
| 'gotcha' | 'pattern' | 'problem-solution' | 'trade-off'
| 'how-it-works' | 'why-it-exists' | 'what-changed'
// Globalizable concepts
| 'best-practice' | 'anti-pattern' | 'architecture'
| 'security' | 'performance' | 'testing' | 'debugging' | 'workflow' | 'tooling'
// Additional useful concepts
| 'refactoring' | 'api' | 'database' | 'configuration' | 'error-handling'
| 'caching' | 'logging' | 'auth' | 'validation'
export interface Observation { export interface Observation {
id: number id: number
@@ -26,13 +35,34 @@ export interface Observation {
export const OBSERVATION_TYPES: ObservationType[] = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change'] export const OBSERVATION_TYPES: ObservationType[] = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change']
export const CONCEPT_TYPES: ConceptType[] = [ export const CONCEPT_TYPES: ConceptType[] = [
// Semantic concepts
'gotcha', 'gotcha',
'pattern', 'pattern',
'problem-solution', 'problem-solution',
'trade-off', 'trade-off',
'how-it-works', 'how-it-works',
'why-it-exists', 'why-it-exists',
'what-changed' 'what-changed',
// Globalizable concepts
'best-practice',
'anti-pattern',
'architecture',
'security',
'performance',
'testing',
'debugging',
'workflow',
'tooling',
// Additional useful concepts
'refactoring',
'api',
'database',
'configuration',
'error-handling',
'caching',
'logging',
'auth',
'validation'
] ]
export const TYPE_CONFIG: Record<ObservationType, { icon: string; colorClass: string; bgClass: string; borderClass: string; gradient: string }> = { export const TYPE_CONFIG: Record<ObservationType, { icon: string; colorClass: string; bgClass: string; borderClass: string; gradient: string }> = {
@@ -44,7 +74,11 @@ export const TYPE_CONFIG: Record<ObservationType, { icon: string; colorClass: st
decision: { icon: 'fa-scale-balanced', colorClass: 'text-yellow-300', bgClass: 'bg-yellow-500/20', borderClass: 'border-yellow-500/30', gradient: 'from-yellow-500 to-yellow-700' }, decision: { icon: 'fa-scale-balanced', colorClass: 'text-yellow-300', bgClass: 'bg-yellow-500/20', borderClass: 'border-yellow-500/30', gradient: 'from-yellow-500 to-yellow-700' },
} }
// Default config for unknown concepts
const DEFAULT_CONCEPT_CONFIG = { icon: 'fa-tag', colorClass: 'text-slate-300', bgClass: 'bg-slate-500/20', borderClass: 'border-slate-500/40' }
export const CONCEPT_CONFIG: Record<ConceptType, { icon: string; colorClass: string; bgClass: string; borderClass: string }> = { export const CONCEPT_CONFIG: Record<ConceptType, { icon: string; colorClass: string; bgClass: string; borderClass: string }> = {
// Semantic concepts
gotcha: { icon: 'fa-triangle-exclamation', colorClass: 'text-red-300', bgClass: 'bg-red-500/20', borderClass: 'border-red-500/40' }, gotcha: { icon: 'fa-triangle-exclamation', colorClass: 'text-red-300', bgClass: 'bg-red-500/20', borderClass: 'border-red-500/40' },
pattern: { icon: 'fa-puzzle-piece', colorClass: 'text-purple-300', bgClass: 'bg-purple-500/20', borderClass: 'border-purple-500/40' }, pattern: { icon: 'fa-puzzle-piece', colorClass: 'text-purple-300', bgClass: 'bg-purple-500/20', borderClass: 'border-purple-500/40' },
'problem-solution': { icon: 'fa-lightbulb', colorClass: 'text-blue-300', bgClass: 'bg-blue-500/20', borderClass: 'border-blue-500/40' }, 'problem-solution': { icon: 'fa-lightbulb', colorClass: 'text-blue-300', bgClass: 'bg-blue-500/20', borderClass: 'border-blue-500/40' },
@@ -52,4 +86,29 @@ export const CONCEPT_CONFIG: Record<ConceptType, { icon: string; colorClass: str
'how-it-works': { icon: 'fa-gear', colorClass: 'text-cyan-300', bgClass: 'bg-cyan-500/20', borderClass: 'border-cyan-500/40' }, 'how-it-works': { icon: 'fa-gear', colorClass: 'text-cyan-300', bgClass: 'bg-cyan-500/20', borderClass: 'border-cyan-500/40' },
'why-it-exists': { icon: 'fa-circle-question', colorClass: 'text-green-300', bgClass: 'bg-green-500/20', borderClass: 'border-green-500/40' }, 'why-it-exists': { icon: 'fa-circle-question', colorClass: 'text-green-300', bgClass: 'bg-green-500/20', borderClass: 'border-green-500/40' },
'what-changed': { icon: 'fa-clipboard-list', colorClass: 'text-slate-300', bgClass: 'bg-slate-500/20', borderClass: 'border-slate-500/40' }, 'what-changed': { icon: 'fa-clipboard-list', colorClass: 'text-slate-300', bgClass: 'bg-slate-500/20', borderClass: 'border-slate-500/40' },
// Globalizable concepts
'best-practice': { icon: 'fa-check-circle', colorClass: 'text-emerald-300', bgClass: 'bg-emerald-500/20', borderClass: 'border-emerald-500/40' },
'anti-pattern': { icon: 'fa-ban', colorClass: 'text-red-300', bgClass: 'bg-red-500/20', borderClass: 'border-red-500/40' },
architecture: { icon: 'fa-sitemap', colorClass: 'text-indigo-300', bgClass: 'bg-indigo-500/20', borderClass: 'border-indigo-500/40' },
security: { icon: 'fa-shield-halved', colorClass: 'text-rose-300', bgClass: 'bg-rose-500/20', borderClass: 'border-rose-500/40' },
performance: { icon: 'fa-gauge-high', colorClass: 'text-orange-300', bgClass: 'bg-orange-500/20', borderClass: 'border-orange-500/40' },
testing: { icon: 'fa-vial', colorClass: 'text-teal-300', bgClass: 'bg-teal-500/20', borderClass: 'border-teal-500/40' },
debugging: { icon: 'fa-bug', colorClass: 'text-amber-300', bgClass: 'bg-amber-500/20', borderClass: 'border-amber-500/40' },
workflow: { icon: 'fa-diagram-project', colorClass: 'text-violet-300', bgClass: 'bg-violet-500/20', borderClass: 'border-violet-500/40' },
tooling: { icon: 'fa-wrench', colorClass: 'text-zinc-300', bgClass: 'bg-zinc-500/20', borderClass: 'border-zinc-500/40' },
// Additional useful concepts
refactoring: { icon: 'fa-rotate', colorClass: 'text-blue-300', bgClass: 'bg-blue-500/20', borderClass: 'border-blue-500/40' },
api: { icon: 'fa-plug', colorClass: 'text-lime-300', bgClass: 'bg-lime-500/20', borderClass: 'border-lime-500/40' },
database: { icon: 'fa-database', colorClass: 'text-sky-300', bgClass: 'bg-sky-500/20', borderClass: 'border-sky-500/40' },
configuration: { icon: 'fa-sliders', colorClass: 'text-fuchsia-300', bgClass: 'bg-fuchsia-500/20', borderClass: 'border-fuchsia-500/40' },
'error-handling': { icon: 'fa-circle-exclamation', colorClass: 'text-red-300', bgClass: 'bg-red-500/20', borderClass: 'border-red-500/40' },
caching: { icon: 'fa-bolt', colorClass: 'text-yellow-300', bgClass: 'bg-yellow-500/20', borderClass: 'border-yellow-500/40' },
logging: { icon: 'fa-file-lines', colorClass: 'text-gray-300', bgClass: 'bg-gray-500/20', borderClass: 'border-gray-500/40' },
auth: { icon: 'fa-key', colorClass: 'text-amber-300', bgClass: 'bg-amber-500/20', borderClass: 'border-amber-500/40' },
validation: { icon: 'fa-check', colorClass: 'text-green-300', bgClass: 'bg-green-500/20', borderClass: 'border-green-500/40' },
}
// Helper to get config with fallback for unknown concepts
export function getConceptConfig(concept: string) {
return CONCEPT_CONFIG[concept as ConceptType] || DEFAULT_CONCEPT_CONFIG
} }
+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/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.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/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/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/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"}