From af94767a43432f5cf83e03eb378fd4e456ede5ef Mon Sep 17 00:00:00 2001 From: andyhtran Date: Fri, 6 Mar 2026 09:18:44 -0500 Subject: [PATCH 1/5] feat: support multi-term AND matching in search Split multi-word queries into individual terms and require all terms to appear somewhere across the session's messages. Single-term queries retain exact substring matching. Co-Authored-By: Claude Opus 4.6 --- internal/session/scan.go | 46 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/internal/session/scan.go b/internal/session/scan.go index d331454..12471b4 100644 --- a/internal/session/scan.go +++ b/internal/session/scan.go @@ -153,9 +153,14 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea } s.ShortID = ShortID(s.ID) + terms := strings.Fields(keyLower) + isPhrase := len(terms) <= 1 + scanner := NewJSONLScanner(f) var matches []string + // For multi-term queries, track which terms have been seen across all messages. + termSeen := make([]bool, len(terms)) for scanner.Scan() { line := scanner.Bytes() @@ -183,14 +188,49 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea continue } - if strings.Contains(strings.ToLower(text), keyLower) { - snippet := output.ExtractSnippet(text, keyLower, snippetWidth) + textLower := strings.ToLower(text) + + if isPhrase { + if strings.Contains(textLower, keyLower) { + snippet := output.ExtractSnippet(text, keyLower, snippetWidth) + matches = append(matches, snippet) + } + continue + } + + // Multi-term: check which terms appear in this message. + var bestTerm string + for i, term := range terms { + if strings.Contains(textLower, term) { + termSeen[i] = true + if bestTerm == "" || len(term) > len(bestTerm) { + bestTerm = term + } + } + } + if bestTerm != "" { + snippet := output.ExtractSnippet(text, bestTerm, snippetWidth) matches = append(matches, snippet) } } // scanner.Err() intentionally not checked — partial results are acceptable. - if len(matches) == 0 { + if isPhrase { + if len(matches) == 0 { + return nil + } + return &SearchResult{Session: s, Matches: matches} + } + + // Multi-term: only return results where ALL terms appeared somewhere in the session. + allSeen := true + for _, seen := range termSeen { + if !seen { + allSeen = false + break + } + } + if !allSeen || len(matches) == 0 { return nil } From d9c9e2673dc6f98593bdbbfe1b1029b15c87487c Mon Sep 17 00:00:00 2001 From: andyhtran Date: Fri, 6 Mar 2026 09:28:18 -0500 Subject: [PATCH 2/5] feat: add role labels to search result snippets Show [u]/[a] prefix in table output and structured role field in JSON to distinguish user vs assistant messages in search matches. Co-Authored-By: Claude Opus 4.6 --- internal/app/search.go | 18 ++++++++++++++++-- internal/session/scan.go | 13 ++++++++----- internal/session/session.go | 7 ++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/internal/app/search.go b/internal/app/search.go index 32a55af..8bfb1a8 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -10,6 +10,19 @@ import ( "github.com/andyhtran/cct/internal/session" ) +var roleTag = map[string]string{ + "user": "[u]", + "assistant": "[a]", +} + +func formatMatchRole(m session.Match) string { + tag := roleTag[m.Role] + if tag == "" { + tag = "[?]" + } + return output.Dim(tag) + " " + m.Snippet +} + type SearchCmd struct { Query string `arg:"" help:"Search query"` Project string `short:"p" help:"Filter by project name"` @@ -62,13 +75,14 @@ func (cmd *SearchCmd) Run(globals *Globals) error { for _, r := range results { s := r.Session for i, m := range r.Matches { + display := formatMatchRole(m) if i == 0 { tbl.Row( - []string{s.ShortID, output.Truncate(s.ProjectName, tbl.ColWidth(1)), output.FormatAge(s.Modified), m}, + []string{s.ShortID, output.Truncate(s.ProjectName, tbl.ColWidth(1)), output.FormatAge(s.Modified), display}, []func(string) string{output.Dim, output.Bold, output.Dim, nil}, ) } else { - tbl.Continuation(m) + tbl.Continuation(display) } } } diff --git a/internal/session/scan.go b/internal/session/scan.go index 12471b4..d4e515a 100644 --- a/internal/session/scan.go +++ b/internal/session/scan.go @@ -158,7 +158,7 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea scanner := NewJSONLScanner(f) - var matches []string + var matches []Match // For multi-term queries, track which terms have been seen across all messages. termSeen := make([]bool, len(terms)) @@ -190,10 +190,13 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea textLower := strings.ToLower(text) + roleWidth := len(lineType) + 3 // "[x] " prefix + sw := snippetWidth - roleWidth + if isPhrase { if strings.Contains(textLower, keyLower) { - snippet := output.ExtractSnippet(text, keyLower, snippetWidth) - matches = append(matches, snippet) + snippet := output.ExtractSnippet(text, keyLower, sw) + matches = append(matches, Match{Role: lineType, Snippet: snippet}) } continue } @@ -209,8 +212,8 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea } } if bestTerm != "" { - snippet := output.ExtractSnippet(text, bestTerm, snippetWidth) - matches = append(matches, snippet) + snippet := output.ExtractSnippet(text, bestTerm, sw) + matches = append(matches, Match{Role: lineType, Snippet: snippet}) } } // scanner.Err() intentionally not checked — partial results are acceptable. diff --git a/internal/session/session.go b/internal/session/session.go index eb58563..6dd0788 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -28,9 +28,14 @@ type Session struct { MessageCount int `json:"message_count"` } +type Match struct { + Role string `json:"role"` + Snippet string `json:"snippet"` +} + type SearchResult struct { Session *Session `json:"session"` - Matches []string `json:"matches"` + Matches []Match `json:"matches"` } func ExtractIDFromFilename(path string) string { From f993eb31ed3cb6516152f83c4a131d9aeb7d0463 Mon Sep 17 00:00:00 2001 From: andyhtran Date: Fri, 6 Mar 2026 09:56:46 -0500 Subject: [PATCH 3/5] feat: add --session flag to search; fix duplicate session ID bug Add -s/--session flag to `cct search` to scope search to a single session, eliminating the need for `cct export | grep`. Fix a bug where sub-agent files (agent-*.jsonl) had their file-derived ID overwritten by the parent session's `sessionId` field from the JSONL content. This caused FindByPrefix to see multiple "different" sessions with the same ID (e.g. the parent file + 2 sub-agent files all claiming to be c8035fd7), returning ErrMultipleMatches and blocking --session, info, export, and resume for any session that had sub-agents. Root cause: extractUserMetadata (parse.go) unconditionally replaced s.ID with the `sessionId` value from the first user message. For regular sessions this was redundant (sessionId == filename). For sub-agent files, `sessionId` pointed to the parent session, causing identity collision. Verified across 501 non-agent files that sessionId always matches the filename, confirming the override was never needed. Fix: remove the sessionId override entirely. The file-derived ID (from the .jsonl filename) is always the correct, unique identifier. This resolved 313 duplicate ID collisions across 2847 sessions. Co-Authored-By: Claude Opus 4.6 --- internal/app/search.go | 16 +++++++++++++--- internal/session/parse.go | 4 ---- internal/session/parse_test.go | 32 ++++++++++++++++++++++++++------ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/internal/app/search.go b/internal/app/search.go index 8bfb1a8..4a0cad1 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -26,6 +26,7 @@ func formatMatchRole(m session.Match) string { type SearchCmd struct { Query string `arg:"" help:"Search query"` Project string `short:"p" help:"Filter by project name"` + Session string `short:"s" help:"Search within a specific session (ID or prefix)"` Limit int `short:"n" help:"Max results (0=no limit)" default:"25"` All bool `short:"a" help:"Show all results"` MaxMatches int `short:"m" help:"Max matches per session" default:"3"` @@ -39,9 +40,18 @@ func (cmd *SearchCmd) Run(globals *Globals) error { output.Flex("MATCH", 0, 30), ) - files := session.DiscoverFiles(cmd.Project) - if !globals.JSON && len(files) > 50 { - fmt.Fprintf(os.Stderr, "Searching %d sessions...\n", len(files)) + var files []string + if cmd.Session != "" { + s, err := session.FindByPrefix(cmd.Session) + if err != nil { + return err + } + files = []string{s.FilePath} + } else { + files = session.DiscoverFiles(cmd.Project) + if !globals.JSON && len(files) > 50 { + fmt.Fprintf(os.Stderr, "Searching %d sessions...\n", len(files)) + } } results := session.SearchFiles(files, cmd.Query, tbl.LastColWidth(), cmd.MaxMatches) diff --git a/internal/session/parse.go b/internal/session/parse.go index e6a3e67..f2e8a36 100644 --- a/internal/session/parse.go +++ b/internal/session/parse.go @@ -162,10 +162,6 @@ func extractUserMetadata(s *Session, obj map[string]any) bool { s.ProjectPath, _ = obj["cwd"].(string) s.ProjectName = filepath.Base(s.ProjectPath) s.GitBranch, _ = obj["gitBranch"].(string) - if sid, ok := obj["sessionId"].(string); ok && sid != "" { - s.ID = sid - s.ShortID = ShortID(sid) - } } if s.FirstPrompt == "" { s.FirstPrompt = ExtractPromptText(obj) diff --git a/internal/session/parse_test.go b/internal/session/parse_test.go index f33d9fb..41e963e 100644 --- a/internal/session/parse_test.go +++ b/internal/session/parse_test.go @@ -193,8 +193,8 @@ func TestExtractMetadata(t *testing.T) { t.Fatal("expected non-nil session") } - if s.ID != "abc-123" { - t.Errorf("ID = %q, want %q", s.ID, "abc-123") + if s.ID != "test-session-id" { + t.Errorf("ID = %q, want %q (file-derived ID must be preserved)", s.ID, "test-session-id") } if s.ProjectPath != "/Users/test/project" { t.Errorf("ProjectPath = %q, want %q", s.ProjectPath, "/Users/test/project") @@ -248,8 +248,8 @@ func TestParseFullSession(t *testing.T) { if s.FirstPrompt != "first prompt" { t.Errorf("FirstPrompt = %q, want %q", s.FirstPrompt, "first prompt") } - if s.ID != "full-123" { - t.Errorf("ID = %q, want %q", s.ID, "full-123") + if s.ID != "test-full-id" { + t.Errorf("ID = %q, want %q (file-derived ID must be preserved)", s.ID, "test-full-id") } } @@ -296,8 +296,8 @@ func TestExtractUserMetadata(t *testing.T) { if s.GitBranch != "feature-x" { t.Errorf("GitBranch = %q, want %q", s.GitBranch, "feature-x") } - if s.ID != "real-session-id-1234" { - t.Errorf("ID = %q, want %q", s.ID, "real-session-id-1234") + if s.ID != "file-id" { + t.Errorf("ID = %q, want %q (sessionId should not override file-derived ID)", s.ID, "file-id") } if s.FirstPrompt != "implement auth" { t.Errorf("FirstPrompt = %q, want %q", s.FirstPrompt, "implement auth") @@ -341,6 +341,26 @@ func TestExtractUserMetadata(t *testing.T) { } }) + t.Run("sub-agent sessionId does not override file-derived ID", func(t *testing.T) { + // Sub-agent files (agent-*.jsonl) contain a sessionId pointing to their + // parent session. The file-derived ID must be preserved to avoid duplicate + // ID collisions when multiple sub-agents share the same parent. + s := &Session{ID: "agent-19b8cb-fake-uuid", ShortID: "agent-19"} + obj := map[string]any{ + "cwd": "/Users/test/project", + "sessionId": "c8035fd7-parent-session-id", + "message": map[string]any{"role": "user", "content": "sub-agent task"}, + } + + extractUserMetadata(s, obj) + if s.ID != "agent-19b8cb-fake-uuid" { + t.Errorf("ID = %q, want %q (sub-agent ID must not be replaced by parent sessionId)", s.ID, "agent-19b8cb-fake-uuid") + } + if s.ShortID != "agent-19" { + t.Errorf("ShortID = %q, want %q", s.ShortID, "agent-19") + } + }) + t.Run("returns false when metadata is incomplete", func(t *testing.T) { s := &Session{} obj := map[string]any{ From 462e586a08e44e5e6adc95d907fcdad0a3249933 Mon Sep 17 00:00:00 2001 From: andyhtran Date: Fri, 6 Mar 2026 11:02:52 -0500 Subject: [PATCH 4/5] feat: filter sub-agent sessions and fix ShortID collisions Exclude agent-*.jsonl files from list and stats by default (--agents to include). Keep agents in search by default (--no-agents to exclude) and tag them with "(agent)" in the PROJECT column. Fix ShortID for agent files: return the full 14-char ID instead of truncating to 8 chars, which caused collisions (only 256 possible values for 2157 agent files). Skip agent sessions in resume hints. Add IsAgentSession helper and IsAgent field to Session struct. Widen SESSION column from 10 to 16 chars to fit agent IDs. Also clean up 7 redundant comments across 4 files. Co-Authored-By: Claude Opus 4.6 --- internal/app/list.go | 25 ++++++----- internal/app/search.go | 25 +++++++---- internal/app/stats.go | 6 ++- internal/session/parse.go | 4 +- internal/session/parse_test.go | 1 - internal/session/scan.go | 10 +++-- internal/session/scan_test.go | 50 ++++++++++++++++------ internal/session/session.go | 10 ++++- internal/session/session_test.go | 72 ++++++++++++++++++++++++++++++++ 9 files changed, 162 insertions(+), 41 deletions(-) diff --git a/internal/app/list.go b/internal/app/list.go index d6028ec..b5699c0 100644 --- a/internal/app/list.go +++ b/internal/app/list.go @@ -13,21 +13,22 @@ import ( type DefaultCmd struct{} func (cmd *DefaultCmd) Run(globals *Globals) error { - return listSessions(globals, "", 5, false, true) + return listSessions(globals, "", 5, false, true, false) } type ListCmd struct { Project string `short:"p" help:"Filter by project name"` Limit int `short:"n" help:"Max results" default:"15"` All bool `short:"a" help:"Show all results"` + Agents bool `help:"Include sub-agent sessions"` } func (cmd *ListCmd) Run(globals *Globals) error { - return listSessions(globals, cmd.Project, cmd.Limit, cmd.All, false) + return listSessions(globals, cmd.Project, cmd.Limit, cmd.All, false, cmd.Agents) } -func listSessions(globals *Globals, project string, limit int, showAll, compact bool) error { - sessions := session.ScanAll(project, false) +func listSessions(globals *Globals, project string, limit int, showAll, compact bool, includeAgents bool) error { + sessions := session.ScanAll(project, false, includeAgents) sort.Slice(sessions, func(i, j int) bool { return sessions[i].Modified.After(sessions[j].Modified) }) @@ -55,7 +56,7 @@ const maxResumeHints = 3 func printSessionTable(sessions []*session.Session, compact bool) { tbl := output.NewTable("", - output.Fixed("SESSION", 10), + output.Fixed("SESSION", 16), output.Flex("PROJECT", 30, 15), output.Fixed("BRANCH", 8), output.Fixed("AGE", 6), @@ -84,12 +85,16 @@ func printSessionTable(sessions []*session.Session, compact bool) { if !compact { fmt.Println() - n := len(sessions) - if n > maxResumeHints { - n = maxResumeHints - } - for _, s := range sessions[:n] { + hints := 0 + for _, s := range sessions { + if hints >= maxResumeHints { + break + } + if s.IsAgent { + continue + } fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct resume %s", s.ShortID))) + hints++ } } fmt.Println() diff --git a/internal/app/search.go b/internal/app/search.go index 4a0cad1..b4f430c 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -30,11 +30,12 @@ type SearchCmd struct { Limit int `short:"n" help:"Max results (0=no limit)" default:"25"` All bool `short:"a" help:"Show all results"` MaxMatches int `short:"m" help:"Max matches per session" default:"3"` + NoAgents bool `help:"Exclude sub-agent sessions" name:"no-agents"` } func (cmd *SearchCmd) Run(globals *Globals) error { tbl := output.NewTable(cmd.Query, - output.Fixed("SESSION", 10), + output.Fixed("SESSION", 16), output.Flex("PROJECT", 25, 15), output.Fixed("AGE", 6), output.Flex("MATCH", 0, 30), @@ -48,7 +49,7 @@ func (cmd *SearchCmd) Run(globals *Globals) error { } files = []string{s.FilePath} } else { - files = session.DiscoverFiles(cmd.Project) + files = session.DiscoverFiles(cmd.Project, !cmd.NoAgents) if !globals.JSON && len(files) > 50 { fmt.Fprintf(os.Stderr, "Searching %d sessions...\n", len(files)) } @@ -84,11 +85,15 @@ func (cmd *SearchCmd) Run(globals *Globals) error { for _, r := range results { s := r.Session + projectName := s.ProjectName + if s.IsAgent { + projectName += " (agent)" + } for i, m := range r.Matches { display := formatMatchRole(m) if i == 0 { tbl.Row( - []string{s.ShortID, output.Truncate(s.ProjectName, tbl.ColWidth(1)), output.FormatAge(s.Modified), display}, + []string{s.ShortID, output.Truncate(projectName, tbl.ColWidth(1)), output.FormatAge(s.Modified), display}, []func(string) string{output.Dim, output.Bold, output.Dim, nil}, ) } else { @@ -98,12 +103,16 @@ func (cmd *SearchCmd) Run(globals *Globals) error { } fmt.Println() - n := len(results) - if n > maxResumeHints { - n = maxResumeHints - } - for _, r := range results[:n] { + hints := 0 + for _, r := range results { + if hints >= maxResumeHints { + break + } + if r.Session.IsAgent { + continue + } fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct resume %s", r.Session.ShortID))) + hints++ } fmt.Println() return nil diff --git a/internal/app/stats.go b/internal/app/stats.go index 5aa90ce..a068d6a 100644 --- a/internal/app/stats.go +++ b/internal/app/stats.go @@ -11,7 +11,9 @@ import ( "github.com/andyhtran/cct/internal/session" ) -type StatsCmd struct{} +type StatsCmd struct { + Agents bool `help:"Include sub-agent sessions"` +} type statsData struct { Total int `json:"total_sessions"` @@ -29,7 +31,7 @@ type projectStat struct { } func (cmd *StatsCmd) Run(globals *Globals) error { - files := session.DiscoverFiles("") + files := session.DiscoverFiles("", cmd.Agents) if !globals.JSON && len(files) > 50 { fmt.Fprintf(os.Stderr, "Scanning %d sessions...\n", len(files)) } diff --git a/internal/session/parse.go b/internal/session/parse.go index f2e8a36..9a2be69 100644 --- a/internal/session/parse.go +++ b/internal/session/parse.go @@ -34,15 +34,12 @@ func NewJSONLScanner(r io.Reader) *bufio.Scanner { } func FastExtractType(line []byte) string { - // Check for top-level message types first - these values are unique to the - // top level and avoid confusion with nested types like "type":"message". if bytes.Contains(line, typeUser) { return "user" } if bytes.Contains(line, typeAssistant) { return "assistant" } - // Fall back to generic extraction for other types idx := bytes.Index(line, typePrefix) if idx < 0 { return "" @@ -196,6 +193,7 @@ func parseSession(path string, full bool) *Session { Modified: info.ModTime(), } s.ShortID = ShortID(s.ID) + s.IsAgent = IsAgentSession(s.ID) scanner := NewJSONLScanner(f) diff --git a/internal/session/parse_test.go b/internal/session/parse_test.go index 41e963e..857b3ae 100644 --- a/internal/session/parse_test.go +++ b/internal/session/parse_test.go @@ -166,7 +166,6 @@ func TestExtractTextFromContent(t *testing.T) { } func TestExtractMetadata(t *testing.T) { - // Create a temp JSONL file dir := t.TempDir() path := filepath.Join(dir, "test-session-id.jsonl") diff --git a/internal/session/scan.go b/internal/session/scan.go index d4e515a..948cae7 100644 --- a/internal/session/scan.go +++ b/internal/session/scan.go @@ -15,7 +15,7 @@ import ( // DiscoverFiles reads exactly one directory level deep under ~/.claude/projects/, // matching Claude Code's current storage layout: //.jsonl. -func DiscoverFiles(projectFilter string) []string { +func DiscoverFiles(projectFilter string, includeAgents bool) []string { dir := paths.ProjectsDir() var files []string entries, err := os.ReadDir(dir) @@ -45,6 +45,9 @@ func DiscoverFiles(projectFilter string) []string { } for _, f := range dirEntries { if !f.IsDir() && strings.HasSuffix(f.Name(), ".jsonl") && f.Name() != "sessions-index.json" { + if !includeAgents && strings.HasPrefix(f.Name(), "agent-") { + continue + } files = append(files, filepath.Join(dirPath, f.Name())) } } @@ -52,8 +55,8 @@ func DiscoverFiles(projectFilter string) []string { return files } -func ScanAll(projectFilter string, fullParse bool) []*Session { - files := DiscoverFiles(projectFilter) +func ScanAll(projectFilter string, fullParse bool, includeAgents bool) []*Session { + files := DiscoverFiles(projectFilter, includeAgents) return ScanFiles(files, fullParse) } @@ -152,6 +155,7 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea Modified: info.ModTime(), } s.ShortID = ShortID(s.ID) + s.IsAgent = IsAgentSession(s.ID) terms := strings.Fields(keyLower) isPhrase := len(terms) <= 1 diff --git a/internal/session/scan_test.go b/internal/session/scan_test.go index 8259e54..9dbc231 100644 --- a/internal/session/scan_test.go +++ b/internal/session/scan_test.go @@ -50,40 +50,64 @@ func TestDiscoverFiles(t *testing.T) { }) t.Run("no filter", func(t *testing.T) { - files := DiscoverFiles("") + files := DiscoverFiles("", true) if len(files) != 3 { t.Errorf("expected 3 files, got %d", len(files)) } }) t.Run("project filter", func(t *testing.T) { - files := DiscoverFiles("myproject") + files := DiscoverFiles("myproject", true) if len(files) != 2 { t.Errorf("expected 2 files for myproject filter, got %d", len(files)) } }) t.Run("filter no match", func(t *testing.T) { - files := DiscoverFiles("nonexistent") + files := DiscoverFiles("nonexistent", true) if len(files) != 0 { t.Errorf("expected 0 files for nonexistent filter, got %d", len(files)) } }) t.Run("case insensitive", func(t *testing.T) { - files := DiscoverFiles("MyProject") + files := DiscoverFiles("MyProject", true) if len(files) != 2 { t.Errorf("expected 2 files for case-insensitive filter, got %d", len(files)) } }) } +func TestDiscoverFiles_AgentFiltering(t *testing.T) { + home := setupTestHome(t) + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-myproject") + writeSessionFile(t, projDir, "sess-aaa", []string{ + `{"type":"user","message":{"role":"user","content":"hello"},"cwd":"/test"}`, + }) + writeSessionFile(t, projDir, "agent-12345678", []string{ + `{"type":"user","message":{"role":"user","content":"agent task"},"cwd":"/test"}`, + }) + + t.Run("excludes agents", func(t *testing.T) { + files := DiscoverFiles("", false) + if len(files) != 1 { + t.Errorf("expected 1 file (no agents), got %d", len(files)) + } + }) + + t.Run("includes agents", func(t *testing.T) { + files := DiscoverFiles("", true) + if len(files) != 2 { + t.Errorf("expected 2 files (with agents), got %d", len(files)) + } + }) +} + func TestDiscoverFiles_MissingDir(t *testing.T) { home := setupTestHome(t) - // Don't create the projects directory. _ = home - files := DiscoverFiles("") + files := DiscoverFiles("", true) if files != nil { t.Errorf("expected nil for missing dir, got %v", files) } @@ -98,7 +122,7 @@ func TestScanAll(t *testing.T) { }) t.Run("quick scan", func(t *testing.T) { - sessions := ScanAll("", false) + sessions := ScanAll("", false, true) if len(sessions) != 1 { t.Fatalf("expected 1 session, got %d", len(sessions)) } @@ -115,7 +139,7 @@ func TestScanAll(t *testing.T) { }) t.Run("full parse", func(t *testing.T) { - sessions := ScanAll("", true) + sessions := ScanAll("", true, true) if len(sessions) != 1 { t.Fatalf("expected 1 session, got %d", len(sessions)) } @@ -126,11 +150,11 @@ func TestScanAll(t *testing.T) { }) t.Run("with project filter", func(t *testing.T) { - sessions := ScanAll("proj", false) + sessions := ScanAll("proj", false, true) if len(sessions) != 1 { t.Fatalf("expected 1 session, got %d", len(sessions)) } - sessions = ScanAll("nonexistent", false) + sessions = ScanAll("nonexistent", false, true) if len(sessions) != 0 { t.Errorf("expected 0 sessions for nonexistent filter, got %d", len(sessions)) } @@ -149,7 +173,7 @@ func TestSearchFiles(t *testing.T) { `{"type":"user","message":{"role":"user","content":"add authentication"},"cwd":"/test","sessionId":"srch-002","timestamp":"2026-01-11T08:00:00Z"}`, }) - files := DiscoverFiles("") + files := DiscoverFiles("", true) t.Run("keyword found", func(t *testing.T) { results := SearchFiles(files, "database", 80, 3) @@ -185,7 +209,7 @@ func TestSearchFiles_ToolResult(t *testing.T) { `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"Read","input":{}},{"type":"tool_result","tool_use_id":"tu1","content":"database_url=postgres://localhost"}]},"timestamp":"2026-01-10T08:00:05Z"}`, }) - files := DiscoverFiles("") + files := DiscoverFiles("", true) t.Run("finds text in tool_result content", func(t *testing.T) { results := SearchFiles(files, "postgres", 80, 3) @@ -219,7 +243,7 @@ func TestSearchFiles_MaxMatches(t *testing.T) { } writeSessionFile(t, projDir, "srch-max", lines) - files := DiscoverFiles("") + files := DiscoverFiles("", true) results := SearchFiles(files, "keyword", 80, 3) if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) diff --git a/internal/session/session.go b/internal/session/session.go index 6dd0788..8490434 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -18,6 +18,7 @@ var ( type Session struct { ID string `json:"id"` ShortID string `json:"short_id"` + IsAgent bool `json:"is_agent"` ProjectPath string `json:"project_path"` ProjectName string `json:"project_name"` GitBranch string `json:"git_branch"` @@ -43,7 +44,14 @@ func ExtractIDFromFilename(path string) string { return strings.TrimSuffix(base, ".jsonl") } +func IsAgentSession(id string) bool { + return strings.HasPrefix(id, "agent-") +} + func ShortID(id string) string { + if IsAgentSession(id) { + return id + } if len(id) >= 8 { return id[:8] } @@ -51,7 +59,7 @@ func ShortID(id string) string { } func FindByPrefix(prefix string) (*Session, error) { - sessions := ScanAll("", false) + sessions := ScanAll("", false, true) var matches []*Session for _, s := range sessions { if s.ID == prefix || strings.HasPrefix(s.ID, prefix) || s.ShortID == prefix { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index dda3ef6..4cb9b71 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -127,6 +127,78 @@ func TestFindByPrefixFull(t *testing.T) { } } +func TestIsAgentSession(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {"agent-12345678", true}, + {"agent-abcdef12", true}, + {"agent-", true}, + {"aaaa1111-2222-3333-4444-555555555555", false}, + {"", false}, + {"agents-not-an-agent", false}, + } + for _, tt := range tests { + if got := IsAgentSession(tt.id); got != tt.want { + t.Errorf("IsAgentSession(%q) = %v, want %v", tt.id, got, tt.want) + } + } +} + +func TestShortID_Agent(t *testing.T) { + tests := []struct { + id string + want string + }{ + {"agent-12345678", "agent-12345678"}, + {"agent-ab", "agent-ab"}, + {"aaaa1111-2222-3333-4444-555555555555", "aaaa1111"}, + {"short", "short"}, + } + for _, tt := range tests { + if got := ShortID(tt.id); got != tt.want { + t.Errorf("ShortID(%q) = %q, want %q", tt.id, got, tt.want) + } + } +} + +func TestFindByPrefix_Agent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-proj") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatal(err) + } + + for _, s := range []struct{ id, content string }{ + {"sess-1111-2222-3333-4444-555555555555", `{"type":"user","message":{"role":"user","content":"regular"},"cwd":"/test"}`}, + {"agent-abcdef12", `{"type":"user","message":{"role":"user","content":"agent task"},"cwd":"/test"}`}, + } { + path := filepath.Join(projDir, s.id+".jsonl") + if err := os.WriteFile(path, []byte(s.content+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + s, err := FindByPrefix("agent-abcdef12") + if err != nil { + t.Fatalf("FindByPrefix agent: %v", err) + } + if !s.IsAgent { + t.Error("expected IsAgent=true for agent session") + } + + s, err = FindByPrefix("sess-1111") + if err != nil { + t.Fatalf("FindByPrefix regular: %v", err) + } + if s.IsAgent { + t.Error("expected IsAgent=false for regular session") + } +} + func TestExtractIDFromFilename(t *testing.T) { tests := []struct { path string From ccc3655de8f060ee4a33cf6f361a3a350b4d1767 Mon Sep 17 00:00:00 2001 From: andyhtran Date: Fri, 6 Mar 2026 13:02:24 -0500 Subject: [PATCH 5/5] feat: search tool_use blocks, add context flag, and simplify internals - Search tool_use blocks: extract tool name and string input values (file paths, commands, patterns, URLs) so they are searchable. Matches show source labels like [a:Read] and [a:Bash]. - Add -C/--context flag to search for wider snippet context. - Add Match.Source field to attribute matches to specific tools. - Extract parallelMap generic helper, deduplicating ScanFiles and SearchFiles worker pool boilerplate. - Extract printResumeHints helper, deduplicating list.go and search.go. - Simplify FindByPrefixFull to return full parse directly. - Simplify exportJSON encoder setup to create writer first. Co-Authored-By: Claude Opus 4.6 --- internal/app/export.go | 9 ++- internal/app/list.go | 26 ++++--- internal/app/plans.go | 1 - internal/app/search.go | 20 +++--- internal/session/parse.go | 91 ++++++++++++++++++++++- internal/session/parse_test.go | 2 +- internal/session/scan.go | 127 +++++++++++++++------------------ internal/session/scan_test.go | 69 +++++++++++++++++- internal/session/session.go | 21 +----- 9 files changed, 244 insertions(+), 122 deletions(-) diff --git a/internal/app/export.go b/internal/app/export.go index 46cc09a..527c103 100644 --- a/internal/app/export.go +++ b/internal/app/export.go @@ -242,18 +242,17 @@ func (cmd *ExportCmd) exportJSON(s *session.Session, roles map[string]bool, maxC Messages: jsonMessages, } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - + var w io.Writer = os.Stdout if cmd.Output != "" { outFile, err := os.Create(cmd.Output) if err != nil { return err } defer func() { _ = outFile.Close() }() - enc = json.NewEncoder(outFile) - enc.SetIndent("", " ") + w = outFile } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") return enc.Encode(out) } diff --git a/internal/app/list.go b/internal/app/list.go index b5699c0..f4985fc 100644 --- a/internal/app/list.go +++ b/internal/app/list.go @@ -54,6 +54,20 @@ func listSessions(globals *Globals, project string, limit int, showAll, compact const maxResumeHints = 3 +func printResumeHints(sessions []*session.Session) { + hints := 0 + for _, s := range sessions { + if hints >= maxResumeHints { + break + } + if s.IsAgent { + continue + } + fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct resume %s", s.ShortID))) + hints++ + } +} + func printSessionTable(sessions []*session.Session, compact bool) { tbl := output.NewTable("", output.Fixed("SESSION", 16), @@ -85,17 +99,7 @@ func printSessionTable(sessions []*session.Session, compact bool) { if !compact { fmt.Println() - hints := 0 - for _, s := range sessions { - if hints >= maxResumeHints { - break - } - if s.IsAgent { - continue - } - fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct resume %s", s.ShortID))) - hints++ - } + printResumeHints(sessions) } fmt.Println() } diff --git a/internal/app/plans.go b/internal/app/plans.go index 0078590..10bcee5 100644 --- a/internal/app/plans.go +++ b/internal/app/plans.go @@ -115,7 +115,6 @@ func (cmd *PlansSearchCmd) Run(globals *Globals) error { return nil } - // Apply limit if !cmd.All && cmd.Limit > 0 && len(matches) > cmd.Limit { total := len(matches) matches = matches[:cmd.Limit] diff --git a/internal/app/search.go b/internal/app/search.go index b4f430c..b5470d6 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -20,6 +20,9 @@ func formatMatchRole(m session.Match) string { if tag == "" { tag = "[?]" } + if m.Source != "" { + tag = tag[:len(tag)-1] + ":" + m.Source + "]" + } return output.Dim(tag) + " " + m.Snippet } @@ -30,6 +33,7 @@ type SearchCmd struct { Limit int `short:"n" help:"Max results (0=no limit)" default:"25"` All bool `short:"a" help:"Show all results"` MaxMatches int `short:"m" help:"Max matches per session" default:"3"` + Context int `short:"C" help:"Extra context characters for snippets" default:"0"` NoAgents bool `help:"Exclude sub-agent sessions" name:"no-agents"` } @@ -54,7 +58,7 @@ func (cmd *SearchCmd) Run(globals *Globals) error { fmt.Fprintf(os.Stderr, "Searching %d sessions...\n", len(files)) } } - results := session.SearchFiles(files, cmd.Query, tbl.LastColWidth(), cmd.MaxMatches) + results := session.SearchFiles(files, cmd.Query, tbl.LastColWidth()+cmd.Context, cmd.MaxMatches) sort.Slice(results, func(i, j int) bool { return results[i].Session.Modified.After(results[j].Session.Modified) @@ -103,17 +107,11 @@ func (cmd *SearchCmd) Run(globals *Globals) error { } fmt.Println() - hints := 0 - for _, r := range results { - if hints >= maxResumeHints { - break - } - if r.Session.IsAgent { - continue - } - fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct resume %s", r.Session.ShortID))) - hints++ + sessions := make([]*session.Session, len(results)) + for i, r := range results { + sessions[i] = r.Session } + printResumeHints(sessions) fmt.Println() return nil } diff --git a/internal/session/parse.go b/internal/session/parse.go index 9a2be69..8db2561 100644 --- a/internal/session/parse.go +++ b/internal/session/parse.go @@ -74,17 +74,31 @@ func ExtractPromptText(obj map[string]any) string { return ExtractTextFromContent(msg["content"]) } +func ExtractPromptBlocks(obj map[string]any) []ContentBlock { + msg, ok := obj["message"].(map[string]any) + if !ok { + return nil + } + return ExtractContentBlocks(msg["content"]) +} + // skipTypes lists content block types that never contain searchable text. var skipTypes = map[string]bool{ "thinking": true, "redacted_thinking": true, "image": true, "document": true, - "tool_use": true, } const maxExtractDepth = 10 +// ContentBlock holds extracted text from a single content block along with its source. +// Source is empty for regular text blocks, or the tool name for tool_use blocks. +type ContentBlock struct { + Text string + Source string +} + // ExtractTextFromContent recursively extracts searchable text from message content. // Content can be a plain string, an array of content blocks, or a single block object. // Blocks may nest content via a "content" field (e.g. tool_result blocks). @@ -92,6 +106,63 @@ func ExtractTextFromContent(content any) string { return extractText(content, 0) } +// ExtractContentBlocks returns individual searchable blocks from message content, +// preserving the source (tool name for tool_use blocks, empty for text). +func ExtractContentBlocks(content any) []ContentBlock { + return extractBlocks(content, 0) +} + +func extractBlocks(content any, depth int) []ContentBlock { + if depth > maxExtractDepth || content == nil { + return nil + } + if str, ok := content.(string); ok { + if isBase64Like(str) { + return nil + } + return []ContentBlock{{Text: str}} + } + if obj, ok := content.(map[string]any); ok { + return extractBlockEntries(obj, depth) + } + arr, ok := content.([]any) + if !ok { + return nil + } + var blocks []ContentBlock + for _, item := range arr { + block, ok := item.(map[string]any) + if !ok { + continue + } + blocks = append(blocks, extractBlockEntries(block, depth)...) + } + return blocks +} + +func extractBlockEntries(block map[string]any, depth int) []ContentBlock { + blockType, _ := block["type"].(string) + if skipTypes[blockType] { + return nil + } + if blockType == "tool_use" { + text := extractToolUseText(block) + if text == "" { + return nil + } + name, _ := block["name"].(string) + return []ContentBlock{{Text: text, Source: name}} + } + var blocks []ContentBlock + if text, ok := block["text"].(string); ok && text != "" && !isBase64Like(text) { + blocks = append(blocks, ContentBlock{Text: text}) + } + if c, exists := block["content"]; exists { + blocks = append(blocks, extractBlocks(c, depth+1)...) + } + return blocks +} + func extractText(content any, depth int) string { if depth > maxExtractDepth || content == nil { return "" @@ -127,6 +198,9 @@ func extractBlockText(block map[string]any, depth int) string { if skipTypes[blockType] { return "" } + if blockType == "tool_use" { + return extractToolUseText(block) + } var parts []string if text, ok := block["text"].(string); ok && text != "" && !isBase64Like(text) { parts = append(parts, text) @@ -139,6 +213,21 @@ func extractBlockText(block map[string]any, depth int) string { return strings.Join(parts, " ") } +func extractToolUseText(block map[string]any) string { + var parts []string + if name, ok := block["name"].(string); ok { + parts = append(parts, name) + } + if input, ok := block["input"].(map[string]any); ok { + for _, v := range input { + if s, ok := v.(string); ok && s != "" && !isBase64Like(s) { + parts = append(parts, s) + } + } + } + return strings.Join(parts, " ") +} + func isBase64Like(s string) bool { return len(s) > 1000 && !strings.Contains(s[:1000], " ") } diff --git a/internal/session/parse_test.go b/internal/session/parse_test.go index 857b3ae..c3c0660 100644 --- a/internal/session/parse_test.go +++ b/internal/session/parse_test.go @@ -78,7 +78,7 @@ func TestExtractTextFromContent(t *testing.T) { } }) - t.Run("array content skips thinking/tool_use", func(t *testing.T) { + t.Run("skips thinking, extracts tool_use name+input only", func(t *testing.T) { content := []any{ map[string]any{"type": "thinking", "text": "hmm"}, map[string]any{"type": "text", "text": "visible"}, diff --git a/internal/session/scan.go b/internal/session/scan.go index 948cae7..15c959f 100644 --- a/internal/session/scan.go +++ b/internal/session/scan.go @@ -60,52 +60,11 @@ func ScanAll(projectFilter string, fullParse bool, includeAgents bool) []*Sessio return ScanFiles(files, fullParse) } -func ScanFiles(files []string, fullParse bool) []*Session { - numWorkers := runtime.NumCPU() - jobs := make(chan string, len(files)) - results := make(chan *Session, len(files)) - var wg sync.WaitGroup - - for i := 0; i < numWorkers; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for path := range jobs { - var s *Session - if fullParse { - s = ParseFullSession(path) - } else { - s = ExtractMetadata(path) - } - if s != nil { - results <- s - } - } - }() - } - - for _, f := range files { - jobs <- f - } - close(jobs) - - go func() { - wg.Wait() - close(results) - }() - - var sessions []*Session - for s := range results { - sessions = append(sessions, s) - } - return sessions -} - -func SearchFiles(files []string, keyword string, snippetWidth int, maxMatches int) []*SearchResult { - keyLower := strings.ToLower(keyword) +// parallelMap applies fn to each file path using a worker pool and collects non-nil results. +func parallelMap[T any](files []string, fn func(string) *T) []*T { numWorkers := runtime.NumCPU() jobs := make(chan string, len(files)) - results := make(chan *SearchResult, len(files)) + results := make(chan *T, len(files)) var wg sync.WaitGroup for i := 0; i < numWorkers; i++ { @@ -113,8 +72,7 @@ func SearchFiles(files []string, keyword string, snippetWidth int, maxMatches in go func() { defer wg.Done() for path := range jobs { - r := searchOneFile(path, keyLower, snippetWidth, maxMatches) - if r != nil { + if r := fn(path); r != nil { results <- r } } @@ -131,13 +89,28 @@ func SearchFiles(files []string, keyword string, snippetWidth int, maxMatches in close(results) }() - var out []*SearchResult + var out []*T for r := range results { out = append(out, r) } return out } +func ScanFiles(files []string, fullParse bool) []*Session { + parse := ExtractMetadata + if fullParse { + parse = ParseFullSession + } + return parallelMap(files, parse) +} + +func SearchFiles(files []string, keyword string, snippetWidth int, maxMatches int) []*SearchResult { + keyLower := strings.ToLower(keyword) + return parallelMap(files, func(path string) *SearchResult { + return searchOneFile(path, keyLower, snippetWidth, maxMatches) + }) +} + func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *SearchResult { f, err := os.Open(path) if err != nil { @@ -163,8 +136,10 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea scanner := NewJSONLScanner(f) var matches []Match - // For multi-term queries, track which terms have been seen across all messages. - termSeen := make([]bool, len(terms)) + var termSeen []bool + if !isPhrase { + termSeen = make([]bool, len(terms)) + } for scanner.Scan() { line := scanner.Bytes() @@ -187,37 +162,47 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea continue } - text := ExtractPromptText(obj) - if text == "" { + blocks := ExtractPromptBlocks(obj) + if len(blocks) == 0 { continue } - textLower := strings.ToLower(text) + for _, block := range blocks { + if maxMatches > 0 && len(matches) >= maxMatches { + break + } - roleWidth := len(lineType) + 3 // "[x] " prefix - sw := snippetWidth - roleWidth + text := block.Text + textLower := strings.ToLower(text) - if isPhrase { - if strings.Contains(textLower, keyLower) { - snippet := output.ExtractSnippet(text, keyLower, sw) - matches = append(matches, Match{Role: lineType, Snippet: snippet}) + roleWidth := len(lineType) + 3 // "[x] " prefix + if block.Source != "" { + roleWidth += len(block.Source) + 1 // ":Tool" suffix } - continue - } + sw := snippetWidth - roleWidth - // Multi-term: check which terms appear in this message. - var bestTerm string - for i, term := range terms { - if strings.Contains(textLower, term) { - termSeen[i] = true - if bestTerm == "" || len(term) > len(bestTerm) { - bestTerm = term + if isPhrase { + if strings.Contains(textLower, keyLower) { + snippet := output.ExtractSnippet(text, keyLower, sw) + matches = append(matches, Match{Role: lineType, Source: block.Source, Snippet: snippet}) } + continue + } + + // Multi-term: check which terms appear in this block. + var bestTerm string + for i, term := range terms { + if strings.Contains(textLower, term) { + termSeen[i] = true + if bestTerm == "" || len(term) > len(bestTerm) { + bestTerm = term + } + } + } + if bestTerm != "" { + snippet := output.ExtractSnippet(text, bestTerm, sw) + matches = append(matches, Match{Role: lineType, Source: block.Source, Snippet: snippet}) } - } - if bestTerm != "" { - snippet := output.ExtractSnippet(text, bestTerm, sw) - matches = append(matches, Match{Role: lineType, Snippet: snippet}) } } // scanner.Err() intentionally not checked — partial results are acceptable. diff --git a/internal/session/scan_test.go b/internal/session/scan_test.go index 9dbc231..3ab8729 100644 --- a/internal/session/scan_test.go +++ b/internal/session/scan_test.go @@ -221,11 +221,74 @@ func TestSearchFiles_ToolResult(t *testing.T) { } }) - t.Run("still skips tool_use", func(t *testing.T) { - // "Read" only appears inside tool_use which should be skipped + t.Run("raw JSON not searchable", func(t *testing.T) { + // Raw JSON like "name":"Read" is not in the extracted text (extracted as "Read /path/...") results := SearchFiles(files, "\"name\":\"Read\"", 80, 3) if len(results) != 0 { - t.Errorf("expected 0 results for tool_use content, got %d", len(results)) + t.Errorf("expected 0 results for raw JSON, got %d", len(results)) + } + }) +} + +func TestSearchFiles_ToolUse(t *testing.T) { + home := setupTestHome(t) + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-proj") + + writeSessionFile(t, projDir, "tool-use-001", []string{ + `{"type":"user","message":{"role":"user","content":"show me the file"},"cwd":"/test","sessionId":"tool-use-001","timestamp":"2026-01-10T08:00:00Z"}`, + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"Read","input":{"file_path":"/path/to/config.go"}},{"type":"tool_result","tool_use_id":"tu1","content":"package config"}]},"timestamp":"2026-01-10T08:00:05Z"}`, + }) + + files := DiscoverFiles("", true) + + t.Run("finds file path in tool_use input", func(t *testing.T) { + results := SearchFiles(files, "config.go", 80, 3) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if len(results[0].Matches) == 0 { + t.Fatal("expected at least 1 match snippet") + } + m := results[0].Matches[0] + if m.Source != "Read" { + t.Errorf("Source = %q, want %q", m.Source, "Read") + } + }) + + t.Run("finds tool name", func(t *testing.T) { + results := SearchFiles(files, "Read", 80, 3) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Matches[0].Source != "Read" { + t.Errorf("Source = %q, want %q", results[0].Matches[0].Source, "Read") + } + }) + + t.Run("finds bash command", func(t *testing.T) { + home2 := setupTestHome(t) + projDir2 := filepath.Join(home2, ".claude", "projects", "-Users-test-proj") + writeSessionFile(t, projDir2, "tool-use-002", []string{ + `{"type":"user","message":{"role":"user","content":"compile the project"},"cwd":"/test","sessionId":"tool-use-002","timestamp":"2026-01-10T08:00:00Z"}`, + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu2","name":"Bash","input":{"command":"go build ./..."}}]},"timestamp":"2026-01-10T08:00:05Z"}`, + }) + files2 := DiscoverFiles("", true) + results := SearchFiles(files2, "go build", 80, 3) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Matches[0].Source != "Bash" { + t.Errorf("Source = %q, want %q", results[0].Matches[0].Source, "Bash") + } + }) + + t.Run("text match has empty source", func(t *testing.T) { + results := SearchFiles(files, "show me the file", 80, 3) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Matches[0].Source != "" { + t.Errorf("Source = %q, want empty for text match", results[0].Matches[0].Source) } }) } diff --git a/internal/session/session.go b/internal/session/session.go index 8490434..9ef35b3 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -31,6 +31,7 @@ type Session struct { type Match struct { Role string `json:"role"` + Source string `json:"source,omitempty"` Snippet string `json:"snippet"` } @@ -86,24 +87,8 @@ func FindByPrefixFull(prefix string) (*Session, error) { if err != nil { return nil, err } - - full := ParseFullSession(s.FilePath) - if full != nil { - s.MessageCount = full.MessageCount - if full.FirstPrompt != "" { - s.FirstPrompt = full.FirstPrompt - } - if full.ProjectPath != "" { - s.ProjectPath = full.ProjectPath - s.ProjectName = full.ProjectName - } - if full.GitBranch != "" { - s.GitBranch = full.GitBranch - } - if !full.Created.IsZero() { - s.Created = full.Created - } + if full := ParseFullSession(s.FilePath); full != nil { + return full, nil } - return s, nil }