// Package models contains domain models for claude-mnemonic. package models import ( "database/sql" "database/sql/driver" "encoding/json" "fmt" "time" ) // ObservationType represents the type of observation. type ObservationType string const ( ObsTypeDecision ObservationType = "decision" ObsTypeBugfix ObservationType = "bugfix" ObsTypeFeature ObservationType = "feature" ObsTypeRefactor ObservationType = "refactor" ObsTypeDiscovery ObservationType = "discovery" ObsTypeChange ObservationType = "change" ) // ObservationScope defines the visibility scope of an observation. type ObservationScope string const ( // ScopeProject means the observation is only visible within the same project. ScopeProject ObservationScope = "project" // ScopeGlobal means the observation is visible across all projects. // Used for best practices, advanced patterns, and generalizable knowledge. ScopeGlobal ObservationScope = "global" ) // GlobalizableConcepts are concept tags that indicate an observation // should be considered for global scope (best practices, patterns, etc.) var GlobalizableConcepts = []string{ "best-practice", "pattern", "anti-pattern", "architecture", "security", "performance", "testing", "debugging", "workflow", "tooling", } // JSONStringArray is a custom type for handling JSON string arrays in SQLite. type JSONStringArray []string // Scan implements sql.Scanner for JSONStringArray. func (j *JSONStringArray) Scan(src interface{}) error { if src == nil { *j = nil return nil } var data []byte switch v := src.(type) { case string: data = []byte(v) case []byte: data = v default: return fmt.Errorf("JSONStringArray: unsupported type %T", src) } if len(data) == 0 { *j = nil return nil } return json.Unmarshal(data, j) } // Value implements driver.Valuer for JSONStringArray. func (j JSONStringArray) Value() (driver.Value, error) { if j == nil { return nil, nil } return json.Marshal(j) } // JSONInt64Map is a custom type for handling JSON int64 maps in SQLite. type JSONInt64Map map[string]int64 // Scan implements sql.Scanner for JSONInt64Map. func (j *JSONInt64Map) Scan(src interface{}) error { if src == nil { *j = nil return nil } var data []byte switch v := src.(type) { case string: data = []byte(v) case []byte: data = v default: return fmt.Errorf("JSONInt64Map: unsupported type %T", src) } if len(data) == 0 { *j = nil return nil } return json.Unmarshal(data, j) } // Value implements driver.Valuer for JSONInt64Map. func (j JSONInt64Map) Value() (driver.Value, error) { if j == nil { return nil, nil } return json.Marshal(j) } // Observation represents a learning extracted from a Claude Code session. type Observation struct { FileMtimes JSONInt64Map `db:"file_mtimes" json:"file_mtimes,omitempty"` SDKSessionID string `db:"sdk_session_id" json:"sdk_session_id"` Project string `db:"project" json:"project"` Scope ObservationScope `db:"scope" json:"scope"` Type ObservationType `db:"type" json:"type"` CreatedAt string `db:"created_at" json:"created_at"` Subtitle sql.NullString `db:"subtitle" json:"subtitle,omitempty"` Title sql.NullString `db:"title" json:"title,omitempty"` Narrative sql.NullString `db:"narrative" json:"narrative,omitempty"` Concepts JSONStringArray `db:"concepts" json:"concepts,omitempty"` FilesRead JSONStringArray `db:"files_read" json:"files_read,omitempty"` FilesModified JSONStringArray `db:"files_modified" json:"files_modified,omitempty"` Facts JSONStringArray `db:"facts" json:"facts,omitempty"` PromptNumber sql.NullInt64 `db:"prompt_number" json:"prompt_number,omitempty"` LastRetrievedAt sql.NullInt64 `db:"last_retrieved_at_epoch" json:"last_retrieved_at_epoch,omitempty"` ScoreUpdatedAt sql.NullInt64 `db:"score_updated_at_epoch" json:"score_updated_at_epoch,omitempty"` DiscoveryTokens int64 `db:"discovery_tokens" json:"discovery_tokens"` ID int64 `db:"id" json:"id"` CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"` ImportanceScore float64 `db:"importance_score" json:"importance_score"` UserFeedback int `db:"user_feedback" json:"user_feedback"` RetrievalCount int `db:"retrieval_count" json:"retrieval_count"` IsStale bool `db:"-" json:"is_stale,omitempty"` IsSuperseded bool `db:"is_superseded" json:"is_superseded,omitempty"` } // ParsedObservation represents an observation parsed from SDK response XML. type ParsedObservation struct { FileMtimes map[string]int64 Type ObservationType Title string Subtitle string Narrative string Scope ObservationScope Facts []string Concepts []string FilesRead []string FilesModified []string } // ToStoredObservation converts a ParsedObservation to the stored Observation format. // Used for similarity comparison before storage. func (p *ParsedObservation) ToStoredObservation() *Observation { return &Observation{ Type: p.Type, Title: sql.NullString{String: p.Title, Valid: p.Title != ""}, Subtitle: sql.NullString{String: p.Subtitle, Valid: p.Subtitle != ""}, Facts: p.Facts, Narrative: sql.NullString{String: p.Narrative, Valid: p.Narrative != ""}, Concepts: p.Concepts, FilesRead: p.FilesRead, FilesModified: p.FilesModified, FileMtimes: p.FileMtimes, } } // DetermineScope determines the appropriate scope based on observation concepts. // Returns ScopeGlobal if any concept matches globalizable patterns, else ScopeProject. func DetermineScope(concepts []string) ObservationScope { for _, concept := range concepts { for _, globalConcept := range GlobalizableConcepts { if concept == globalConcept { return ScopeGlobal } } } return ScopeProject } // ObservationJSON is a JSON-friendly representation of Observation. // It converts sql.NullString to plain strings for clean JSON output. type ObservationJSON struct { FileMtimes map[string]int64 `json:"file_mtimes,omitempty"` Subtitle string `json:"subtitle,omitempty"` SDKSessionID string `json:"sdk_session_id"` Scope ObservationScope `json:"scope"` Type ObservationType `json:"type"` Title string `json:"title,omitempty"` CreatedAt string `json:"created_at"` Narrative string `json:"narrative,omitempty"` Project string `json:"project"` Concepts []string `json:"concepts,omitempty"` Facts []string `json:"facts,omitempty"` FilesRead []string `json:"files_read,omitempty"` FilesModified []string `json:"files_modified,omitempty"` CreatedAtEpoch int64 `json:"created_at_epoch"` DiscoveryTokens int64 `json:"discovery_tokens"` ID int64 `json:"id"` PromptNumber int64 `json:"prompt_number,omitempty"` ImportanceScore float64 `json:"importance_score"` UserFeedback int `json:"user_feedback"` RetrievalCount int `json:"retrieval_count"` LastRetrievedAt int64 `json:"last_retrieved_at_epoch,omitempty"` ScoreUpdatedAt int64 `json:"score_updated_at_epoch,omitempty"` IsStale bool `json:"is_stale,omitempty"` IsSuperseded bool `json:"is_superseded,omitempty"` } // MarshalJSON implements json.Marshaler for Observation. // Converts sql.NullString fields to plain strings. func (o *Observation) MarshalJSON() ([]byte, error) { j := ObservationJSON{ ID: o.ID, SDKSessionID: o.SDKSessionID, Project: o.Project, Scope: o.Scope, Type: o.Type, Facts: o.Facts, Concepts: o.Concepts, FilesRead: o.FilesRead, FilesModified: o.FilesModified, FileMtimes: o.FileMtimes, DiscoveryTokens: o.DiscoveryTokens, CreatedAt: o.CreatedAt, CreatedAtEpoch: o.CreatedAtEpoch, IsStale: o.IsStale, // Importance scoring fields ImportanceScore: o.ImportanceScore, UserFeedback: o.UserFeedback, RetrievalCount: o.RetrievalCount, // Conflict detection fields IsSuperseded: o.IsSuperseded, } if o.Title.Valid { j.Title = o.Title.String } if o.Subtitle.Valid { j.Subtitle = o.Subtitle.String } if o.Narrative.Valid { j.Narrative = o.Narrative.String } if o.PromptNumber.Valid { j.PromptNumber = o.PromptNumber.Int64 } if o.LastRetrievedAt.Valid { j.LastRetrievedAt = o.LastRetrievedAt.Int64 } if o.ScoreUpdatedAt.Valid { j.ScoreUpdatedAt = o.ScoreUpdatedAt.Int64 } return json.Marshal(j) } // NewObservation creates a new observation from parsed data. func NewObservation(sdkSessionID, project string, parsed *ParsedObservation, promptNumber int, discoveryTokens int64) *Observation { now := time.Now() // Determine scope: use parsed scope if set, otherwise auto-determine from concepts scope := parsed.Scope if scope == "" { scope = DetermineScope(parsed.Concepts) } return &Observation{ SDKSessionID: sdkSessionID, Project: project, Scope: scope, Type: parsed.Type, Title: sql.NullString{String: parsed.Title, Valid: parsed.Title != ""}, Subtitle: sql.NullString{String: parsed.Subtitle, Valid: parsed.Subtitle != ""}, Facts: parsed.Facts, Narrative: sql.NullString{String: parsed.Narrative, Valid: parsed.Narrative != ""}, Concepts: parsed.Concepts, FilesRead: parsed.FilesRead, FilesModified: parsed.FilesModified, FileMtimes: parsed.FileMtimes, PromptNumber: sql.NullInt64{Int64: int64(promptNumber), Valid: promptNumber > 0}, DiscoveryTokens: discoveryTokens, CreatedAt: now.Format(time.RFC3339), CreatedAtEpoch: now.UnixMilli(), // Importance scoring: new observations start with score 1.0 ImportanceScore: 1.0, UserFeedback: 0, RetrievalCount: 0, } } // ToMap converts the observation to a map for JSON response building. // This allows adding extra fields like similarity scores. func (o *Observation) ToMap() map[string]interface{} { // Marshal to JSON then unmarshal to map (uses MarshalJSON for proper conversion) data, err := json.Marshal(o) if err != nil { return map[string]interface{}{"id": o.ID, "error": err.Error()} } var result map[string]interface{} if err := json.Unmarshal(data, &result); err != nil { return map[string]interface{}{"id": o.ID, "error": err.Error()} } return result } // CheckStaleness checks if an observation is stale based on current file mtimes. // Returns true if any tracked file has been modified since the observation was created. func (o *Observation) CheckStaleness(currentMtimes map[string]int64) bool { if len(o.FileMtimes) == 0 { return false // No file tracking, assume fresh } for path, recordedMtime := range o.FileMtimes { if currentMtime, exists := currentMtimes[path]; exists { if currentMtime > recordedMtime { return true // File was modified since observation was created } } // If file doesn't exist in currentMtimes, it may have been deleted // We don't mark as stale for missing files - they might just not be checked } return false }