Additional abstractions for both sqlite and chroma.

This commit is contained in:
2025-12-16 01:36:43 +00:00
parent c40fa7317b
commit 6a685a79c2
20 changed files with 834 additions and 483 deletions
+13 -47
View File
@@ -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
}
+9 -37
View File
@@ -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
}
+10 -40
View File
@@ -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
}
+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.
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
+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
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)
}
+7 -89
View File
@@ -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)
}
+15 -45
View File
@@ -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
+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"
"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
}
}
}
+22 -1
View File
@@ -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,
}
)
+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")
// 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:
<observation>
@@ -794,5 +763,7 @@ When you find something worth remembering, output:
</files_modified>
</observation>
If the tool execution is not noteworthy, simply respond with:
<skip reason="not significant"/>`
If the tool execution is truly trivial (just a directory listing, empty result, etc.), respond with:
<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/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)
+94
View File
@@ -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)
}
+2 -2
View File
@@ -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"
},
+1 -1
View File
@@ -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": {
+6 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { FilterType, ObservationType, ConceptType } from '@/types'
import { OBSERVATION_TYPES, CONCEPT_TYPES } from '@/types/observation'
import { useTypes } from '@/composables/useTypes'
defineProps<{
currentFilter: FilterType
@@ -16,6 +16,9 @@ const emit = defineEmits<{
'update:conceptFilter': [concept: ConceptType | null]
}>()
// Fetch types from API (cached)
const { observationTypes, conceptTypes } = useTypes()
const tabs: { key: FilterType; label: string; icon: string }[] = [
{ key: 'all', label: 'All', icon: 'fa-layer-group' },
{ 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)"
>
<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 }}
</option>
</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)"
>
<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 }}
</option>
</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 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 {
id: number
@@ -26,13 +35,34 @@ export interface Observation {
export const OBSERVATION_TYPES: ObservationType[] = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change']
export const CONCEPT_TYPES: ConceptType[] = [
// Semantic concepts
'gotcha',
'pattern',
'problem-solution',
'trade-off',
'how-it-works',
'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 }> = {
@@ -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' },
}
// 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 }> = {
// Semantic concepts
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' },
'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' },
'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' },
// 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"}