Files
claude-mnemonic/internal/search/manager.go
lukaszraczylo 4f4b4ac70f feat(chunking): add AST-aware code chunking for Go, Python, TypeScript
- [x] Add language-specific chunkers with AST parsing (Go, Python, TypeScript)
- [x] Implement chunking manager to dispatch files to appropriate chunkers
- [x] Integrate code chunks into vector sync for semantic search
- [x] Add tree-sitter dependency for Python/TypeScript parsing
- [x] Reorder struct fields for consistency across codebase
- [x] Rename error variables to follow Go conventions (err → unmarshalErr, etc.)
- [x] Add code chunk metadata to vector documents (language, symbol name, line ranges)
- [x] Update worker service to initialize chunking pipeline with all three languages
2026-01-07 13:19:58 +00:00

312 lines
8.8 KiB
Go

// Package search provides unified search capabilities for claude-mnemonic.
package search
import (
"context"
"strings"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
// Manager provides unified search across SQLite and sqlite-vec.
type Manager struct {
observationStore *sqlite.ObservationStore
summaryStore *sqlite.SummaryStore
promptStore *sqlite.PromptStore
vectorClient *sqlitevec.Client
}
// NewManager creates a new search manager.
func NewManager(
observationStore *sqlite.ObservationStore,
summaryStore *sqlite.SummaryStore,
promptStore *sqlite.PromptStore,
vectorClient *sqlitevec.Client,
) *Manager {
return &Manager{
observationStore: observationStore,
summaryStore: summaryStore,
promptStore: promptStore,
vectorClient: vectorClient,
}
}
// SearchParams contains parameters for unified search.
type SearchParams struct {
Format string
Type string
Project string
ObsType string
Concepts string
Files string
Query string
Scope string
OrderBy string
DateStart int64
Offset int
Limit int
DateEnd int64
IncludeGlobal bool
ExcludeSuperseded bool
}
// SearchResult represents a unified search result.
type SearchResult struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Project string `json:"project"`
Scope string `json:"scope,omitempty"`
ID int64 `json:"id"`
CreatedAt int64 `json:"created_at_epoch"`
Score float64 `json:"score,omitempty"`
}
// UnifiedSearchResult contains the combined search results.
type UnifiedSearchResult struct {
Query string `json:"query,omitempty"`
Results []SearchResult `json:"results"`
TotalCount int `json:"total_count"`
}
// UnifiedSearch performs a unified search across all document types.
func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
if params.OrderBy == "" {
params.OrderBy = "date_desc"
}
// If query is provided and vector client is available, use vector search
if params.Query != "" && m.vectorClient != nil && m.vectorClient.IsConnected() {
return m.vectorSearch(ctx, params)
}
// Otherwise fall back to structured filter search
return m.filterSearch(ctx, params)
}
// vectorSearch performs semantic search via sqlite-vec.
func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
// Build where filter based on search type
var docType sqlitevec.DocType
switch params.Type {
case "observations":
docType = sqlitevec.DocTypeObservation
case "sessions":
docType = sqlitevec.DocTypeSessionSummary
case "prompts":
docType = sqlitevec.DocTypeUserPrompt
}
where := sqlitevec.BuildWhereFilter(docType, params.Project)
// Query sqlite-vec
vectorResults, err := m.vectorClient.Query(ctx, params.Query, params.Limit*2, where)
if err != nil {
// Fall back to filter search on error
return m.filterSearch(ctx, params)
}
// Extract IDs grouped by document type using shared helper
extracted := sqlitevec.ExtractIDsByDocType(vectorResults)
obsIDs := extracted.ObservationIDs
summaryIDs := extracted.SummaryIDs
promptIDs := extracted.PromptIDs
// Fetch full records from SQLite
var results []SearchResult
if len(obsIDs) > 0 && (params.Type == "" || params.Type == "observations") {
obs, err := m.observationStore.GetObservationsByIDs(ctx, obsIDs, params.OrderBy, 0)
if err == nil {
for _, o := range obs {
// Skip superseded observations when requested
if params.ExcludeSuperseded && o.IsSuperseded {
continue
}
results = append(results, m.observationToResult(o, params.Format))
}
}
}
if len(summaryIDs) > 0 && (params.Type == "" || params.Type == "sessions") {
summaries, err := m.summaryStore.GetSummariesByIDs(ctx, summaryIDs, params.OrderBy, 0)
if err == nil {
for _, s := range summaries {
results = append(results, m.summaryToResult(s, params.Format))
}
}
}
if len(promptIDs) > 0 && (params.Type == "" || params.Type == "prompts") {
prompts, err := m.promptStore.GetPromptsByIDs(ctx, promptIDs, params.OrderBy, 0)
if err == nil {
for _, p := range prompts {
results = append(results, m.promptToResult(p, params.Format))
}
}
}
// Apply limit
if len(results) > params.Limit {
results = results[:params.Limit]
}
return &UnifiedSearchResult{
Results: results,
TotalCount: len(results),
Query: params.Query,
}, nil
}
// filterSearch performs structured filter search via SQLite.
func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
var results []SearchResult
// Search observations
if params.Type == "" || params.Type == "observations" {
var obs []*models.Observation
var err error
// Use active observations (excluding superseded) when requested
if params.ExcludeSuperseded {
obs, err = m.observationStore.GetActiveObservations(ctx, params.Project, params.Limit)
} else {
obs, err = m.observationStore.GetRecentObservations(ctx, params.Project, params.Limit)
}
if err == nil {
for _, o := range obs {
results = append(results, m.observationToResult(o, params.Format))
}
}
}
// Search summaries
if params.Type == "" || params.Type == "sessions" {
summaries, err := m.summaryStore.GetRecentSummaries(ctx, params.Project, params.Limit)
if err == nil {
for _, s := range summaries {
results = append(results, m.summaryToResult(s, params.Format))
}
}
}
// Apply limit
if len(results) > params.Limit {
results = results[:params.Limit]
}
return &UnifiedSearchResult{
Results: results,
TotalCount: len(results),
}, nil
}
// Decisions performs a semantic search optimized for finding decisions.
func (m *Manager) Decisions(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
// Boost query with decision-related keywords
if params.Query != "" {
params.Query = params.Query + " decision chose architecture"
}
params.Type = "observations"
return m.UnifiedSearch(ctx, params)
}
// Changes performs a semantic search optimized for finding code changes.
func (m *Manager) Changes(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
// Boost query with change-related keywords
if params.Query != "" {
params.Query = params.Query + " changed modified refactored"
}
params.Type = "observations"
return m.UnifiedSearch(ctx, params)
}
// HowItWorks performs a semantic search optimized for understanding architecture.
func (m *Manager) HowItWorks(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
// Boost query with architecture-related keywords
if params.Query != "" {
params.Query = params.Query + " architecture design pattern implements"
}
params.Type = "observations"
return m.UnifiedSearch(ctx, params)
}
// Helper methods
func (m *Manager) observationToResult(obs *models.Observation, format string) SearchResult {
result := SearchResult{
Type: "observation",
ID: obs.ID,
Project: obs.Project,
Scope: string(obs.Scope),
CreatedAt: obs.CreatedAtEpoch,
Metadata: map[string]interface{}{
"obs_type": string(obs.Type),
"scope": string(obs.Scope),
},
}
if obs.Title.Valid {
result.Title = obs.Title.String
}
if format == "full" && obs.Narrative.Valid {
result.Content = obs.Narrative.String
}
return result
}
func (m *Manager) summaryToResult(summary *models.SessionSummary, format string) SearchResult {
result := SearchResult{
Type: "session",
ID: summary.ID,
Project: summary.Project,
CreatedAt: summary.CreatedAtEpoch,
}
if summary.Request.Valid {
result.Title = truncate(summary.Request.String, 100)
}
if format == "full" && summary.Learned.Valid {
result.Content = summary.Learned.String
}
return result
}
func (m *Manager) promptToResult(prompt *models.UserPromptWithSession, format string) SearchResult {
result := SearchResult{
Type: "prompt",
ID: prompt.ID,
Project: prompt.Project,
CreatedAt: prompt.CreatedAtEpoch,
}
result.Title = truncate(prompt.PromptText, 100)
if format == "full" {
result.Content = prompt.PromptText
}
return result
}
func truncate(s string, maxLen int) string {
s = strings.TrimSpace(s)
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}