Skip to content

Commit 72fc1b4

Browse files
brtkwrclaude
andcommitted
feat: add performance improvements with filtering options
- Add --max-age flag to filter by file modification time (default: 60 days) - Add --max-size flag to skip large files (default: 1GB) - Add --all flag to include everything (no limits) - Use worker pool pattern (8 workers) instead of semaphore to avoid ulimit issues - Show version in title bar - Remove unused timestamp extraction functions - Add tests for mtime and size filtering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e167de7 commit 72fc1b4

5 files changed

Lines changed: 180 additions & 33 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ go test -v -cover
1616
### Run locally
1717

1818
```bash
19-
./ccs
20-
./ccs <query>
21-
./ccs -- --plan # pass flags to claude
19+
./ccs # search recent (60 days, <1GB files)
20+
./ccs <query> # search with initial query
21+
./ccs --max-age=7 # last 7 days only
22+
./ccs --all # include everything
23+
./ccs -- --plan # pass flags to claude
2224
```
2325

2426
## Release Process
@@ -71,8 +73,8 @@ go test -v -cover
7173

7274
### Key Functions
7375

74-
- `getConversations()` - Loads all conversations from `~/.claude/projects/`
75-
- `parseConversationFile()` - Parses JSONL conversation files
76+
- `getConversations(cutoff, maxSize)` - Loads conversations from `~/.claude/projects/` with filters
77+
- `parseConversationFile(path, cutoff, maxSize)` - Parses JSONL files, skips by mtime/size
7678
- `buildItems()` - Creates list items with searchable text
7779
- `initialModel()` - Sets up bubbletea TUI
7880
- `Update()` - Handles keyboard/mouse input

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# CCS — Claude Instructions
2+
3+
When updating project memory, update AGENTS.md instead of this file.
4+
5+
@AGENTS.md

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,19 +44,33 @@ Download the binary from [releases](https://github.com/agentic-utils/ccs/release
4444
## Usage
4545

4646
```bash
47-
# Search and resume a conversation
47+
# Search recent conversations (last 60 days, files <1GB)
4848
ccs
4949

5050
# Search with initial query
5151
ccs buyer
5252

53+
# Search last 7 days only
54+
ccs --max-age=7
55+
56+
# Search everything (all time, all files)
57+
ccs --all
58+
5359
# Resume with plan mode
5460
ccs -- --plan
5561

5662
# Combined: search "buyer", resume with plan mode
5763
ccs buyer -- --plan
5864
```
5965

66+
### Flags
67+
68+
| Flag | Default | Description |
69+
|------|---------|-------------|
70+
| `--max-age=N` | 60 | Only search files modified in the last N days (0 = no limit) |
71+
| `--max-size=N` | 1024 | Max file size in MB to include (0 = no limit) |
72+
| `--all` | - | Include everything (same as `--max-age=0 --max-size=0`) |
73+
6074
### Keybindings
6175

6276
- `↑/↓` or `Ctrl+P/N` - Navigate list

main.go

Lines changed: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -255,13 +255,14 @@ func (m model) View() string {
255255
tableWidth := 97
256256

257257
// Title line with help right-aligned
258-
// Display widths: title="ccs · claude code search"=25, help="↑/↓ Enter Ctrl+J/K Esc"=22
259-
titlePadding := tableWidth - 2 - 25 - 22 + 1 // 2 for indent, +1 to shift right
258+
title := fmt.Sprintf("ccs · claude code search · %s", version)
259+
help := "↑/↓ Enter Ctrl+J/K Esc"
260+
titlePadding := tableWidth - 2 - len(title) - len(help)
260261
if titlePadding < 1 {
261262
titlePadding = 1
262263
}
263-
b.WriteString(fmt.Sprintf(" \033[1;36mccs\033[0m \033[90m· claude code search%s↑/↓ Enter Ctrl+J/K Esc\033[0m\n",
264-
strings.Repeat(" ", titlePadding)))
264+
b.WriteString(fmt.Sprintf(" \033[1;36mccs\033[0m \033[90m· claude code search · %s%s%s\033[0m\n",
265+
version, strings.Repeat(" ", titlePadding), help))
265266

266267
// Search line with count right-aligned
267268
count := fmt.Sprintf("(%d/%d)", len(m.filtered), len(m.items))
@@ -547,7 +548,11 @@ func extractText(content json.RawMessage) string {
547548
return ""
548549
}
549550

550-
func parseConversationFile(path string) (*Conversation, error) {
551+
552+
553+
554+
555+
func parseConversationFile(path string, cutoff time.Time, maxSize int64) (*Conversation, error) {
551556
info, err := os.Stat(path)
552557
if err != nil {
553558
return nil, err
@@ -557,6 +562,16 @@ func parseConversationFile(path string) (*Conversation, error) {
557562
return nil, nil
558563
}
559564

565+
// Skip files larger than maxSize (0 means no limit)
566+
if maxSize > 0 && info.Size() > maxSize {
567+
return nil, nil
568+
}
569+
570+
// Skip files not modified since cutoff (file mtime check)
571+
if !cutoff.IsZero() && info.ModTime().Before(cutoff) {
572+
return nil, nil
573+
}
574+
560575
sessionID := strings.TrimSuffix(info.Name(), ".jsonl")
561576
conv := &Conversation{SessionID: sessionID}
562577

@@ -570,8 +585,10 @@ func parseConversationFile(path string) (*Conversation, error) {
570585
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024)
571586

572587
for scanner.Scan() {
588+
lineBytes := scanner.Bytes()
589+
573590
var raw RawMessage
574-
if err := json.Unmarshal(scanner.Bytes(), &raw); err != nil {
591+
if err := json.Unmarshal(lineBytes, &raw); err != nil {
575592
continue
576593
}
577594

@@ -615,7 +632,7 @@ func parseConversationFile(path string) (*Conversation, error) {
615632
return conv, nil
616633
}
617634

618-
func getConversations() ([]Conversation, error) {
635+
func getConversations(cutoff time.Time, maxSize int64) ([]Conversation, error) {
619636
projectsDir := getProjectsDir()
620637

621638
var files []string
@@ -632,24 +649,30 @@ func getConversations() ([]Conversation, error) {
632649
return nil, err
633650
}
634651

635-
var wg sync.WaitGroup
652+
// Worker pool to limit concurrent file operations
653+
const numWorkers = 8
654+
jobs := make(chan string, len(files))
636655
results := make(chan *Conversation, len(files))
637-
sem := make(chan struct{}, 20)
638656

639-
for _, file := range files {
657+
var wg sync.WaitGroup
658+
for i := 0; i < numWorkers; i++ {
640659
wg.Add(1)
641-
go func(path string) {
660+
go func() {
642661
defer wg.Done()
643-
sem <- struct{}{}
644-
defer func() { <-sem }()
645-
646-
conv, err := parseConversationFile(path)
647-
if err == nil && conv != nil {
648-
results <- conv
662+
for path := range jobs {
663+
conv, err := parseConversationFile(path, cutoff, maxSize)
664+
if err == nil && conv != nil {
665+
results <- conv
666+
}
649667
}
650-
}(file)
668+
}()
651669
}
652670

671+
for _, file := range files {
672+
jobs <- file
673+
}
674+
close(jobs)
675+
653676
go func() {
654677
wg.Wait()
655678
close(results)
@@ -728,12 +751,17 @@ Arguments:
728751
-- claude-flags Flags to pass to 'claude --resume' (after --)
729752
730753
Flags:
731-
-h, --help Show this help message
732-
-v, --version Show version
733-
--dump [query] Debug: print all search items (with optional highlighting)
754+
-h, --help Show this help message
755+
-v, --version Show version
756+
--max-age=N Only search last N days (default: 60, 0 = no limit)
757+
--max-size=N Max file size in MB (default: 1024, 0 = no limit)
758+
--all Include everything (same as --max-age=0 --max-size=0)
759+
--dump [query] Debug: print all search items (with optional highlighting)
734760
735761
Examples:
736-
ccs Search all conversations
762+
ccs Search last 60 days, files <1GB (default)
763+
ccs --max-age=7 Search last 7 days only
764+
ccs --all Search everything (all time, all files)
737765
ccs buyer Search with initial query "buyer"
738766
ccs -- --plan Resume with plan mode
739767
ccs buyer -- --plan Search "buyer", resume with plan mode
@@ -763,14 +791,39 @@ func main() {
763791
}
764792
}
765793

794+
// Parse flags
795+
maxAgeDays := 60 // Default to 60 days
796+
maxSizeMB := int64(1024) // Default to 1GB
797+
for _, arg := range args {
798+
if arg == "--all" {
799+
maxAgeDays = 0
800+
maxSizeMB = 0
801+
} else if strings.HasPrefix(arg, "--max-age=") {
802+
val := strings.TrimPrefix(arg, "--max-age=")
803+
fmt.Sscanf(val, "%d", &maxAgeDays)
804+
} else if strings.HasPrefix(arg, "--max-size=") {
805+
val := strings.TrimPrefix(arg, "--max-size=")
806+
fmt.Sscanf(val, "%d", &maxSizeMB)
807+
}
808+
}
809+
810+
// Convert to bytes (0 means no limit)
811+
maxSize := maxSizeMB * 1024 * 1024
812+
813+
// Calculate cutoff time (0 means no limit)
814+
var cutoff time.Time
815+
if maxAgeDays > 0 {
816+
cutoff = time.Now().AddDate(0, 0, -maxAgeDays)
817+
}
818+
766819
// Debug mode - dump search lines
767820
for i, arg := range args {
768821
if arg == "--dump" {
769822
filter := ""
770823
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
771824
filter = args[i+1]
772825
}
773-
conversations, _ := getConversations()
826+
conversations, _ := getConversations(cutoff, maxSize)
774827
items := buildItems(conversations)
775828
for _, item := range items {
776829
line := item.searchText
@@ -791,6 +844,10 @@ func main() {
791844
claudeFlags = args[i+1:]
792845
break
793846
}
847+
// Skip our flags when looking for filter query
848+
if arg == "--all" || strings.HasPrefix(arg, "--max-age=") || strings.HasPrefix(arg, "--max-size=") {
849+
continue
850+
}
794851
if !strings.HasPrefix(arg, "-") && filterQuery == "" {
795852
filterQuery = arg
796853
}
@@ -804,7 +861,7 @@ func main() {
804861
}
805862

806863
fmt.Fprint(os.Stderr, "Loading conversations...")
807-
conversations, err := getConversations()
864+
conversations, err := getConversations(cutoff, maxSize)
808865
if err != nil {
809866
fmt.Fprintf(os.Stderr, "\rError loading conversations: %v\n", err)
810867
os.Exit(1)

main_test.go

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77
"strings"
88
"testing"
9+
"time"
910
)
1011

1112
func TestTruncate(t *testing.T) {
@@ -239,7 +240,7 @@ func TestParseConversationFile(t *testing.T) {
239240
t.Fatalf("failed to write test file: %v", err)
240241
}
241242

242-
conv, err := parseConversationFile(testFile)
243+
conv, err := parseConversationFile(testFile, time.Time{}, 0) // No cutoff, no size limit
243244
if err != nil {
244245
t.Fatalf("parseConversationFile failed: %v", err)
245246
}
@@ -278,7 +279,7 @@ func TestParseConversationFileSkipsAgentFiles(t *testing.T) {
278279
t.Fatalf("failed to write test file: %v", err)
279280
}
280281

281-
conv, err := parseConversationFile(testFile)
282+
conv, err := parseConversationFile(testFile, time.Time{}, 0) // No cutoff, no size limit
282283
if err != nil {
283284
t.Fatalf("parseConversationFile failed: %v", err)
284285
}
@@ -297,7 +298,7 @@ func TestParseConversationFileEmptyMessages(t *testing.T) {
297298
t.Fatalf("failed to write test file: %v", err)
298299
}
299300

300-
conv, err := parseConversationFile(testFile)
301+
conv, err := parseConversationFile(testFile, time.Time{}, 0) // No cutoff, no size limit
301302
if err != nil {
302303
t.Fatalf("parseConversationFile failed: %v", err)
303304
}
@@ -307,3 +308,71 @@ func TestParseConversationFileEmptyMessages(t *testing.T) {
307308
}
308309
}
309310

311+
func TestParseConversationFileSkipsOldFiles(t *testing.T) {
312+
tmpDir := t.TempDir()
313+
testFile := filepath.Join(tmpDir, "old-session.jsonl")
314+
315+
content := `{"type":"user","cwd":"/test","message":{"content":"hello"},"timestamp":"2024-01-15T10:00:00Z"}`
316+
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
317+
t.Fatalf("failed to write test file: %v", err)
318+
}
319+
320+
// Set file mtime to 60 days ago
321+
oldTime := time.Now().AddDate(0, 0, -60)
322+
if err := os.Chtimes(testFile, oldTime, oldTime); err != nil {
323+
t.Fatalf("failed to set file mtime: %v", err)
324+
}
325+
326+
// Cutoff is 30 days ago - file should be skipped
327+
cutoff := time.Now().AddDate(0, 0, -30)
328+
conv, err := parseConversationFile(testFile, cutoff, 0)
329+
if err != nil {
330+
t.Fatalf("parseConversationFile failed: %v", err)
331+
}
332+
333+
if conv != nil {
334+
t.Error("parseConversationFile should return nil for files older than cutoff")
335+
}
336+
}
337+
338+
func TestParseConversationFileIncludesRecentFiles(t *testing.T) {
339+
tmpDir := t.TempDir()
340+
testFile := filepath.Join(tmpDir, "recent-session.jsonl")
341+
342+
content := `{"type":"user","cwd":"/test","message":{"content":"hello"},"timestamp":"2024-01-15T10:00:00Z"}`
343+
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
344+
t.Fatalf("failed to write test file: %v", err)
345+
}
346+
347+
// File mtime is now (recent) - cutoff is 30 days ago
348+
cutoff := time.Now().AddDate(0, 0, -30)
349+
conv, err := parseConversationFile(testFile, cutoff, 0)
350+
if err != nil {
351+
t.Fatalf("parseConversationFile failed: %v", err)
352+
}
353+
354+
if conv == nil {
355+
t.Error("parseConversationFile should include files newer than cutoff")
356+
}
357+
}
358+
359+
func TestParseConversationFileSkipsLargeFiles(t *testing.T) {
360+
tmpDir := t.TempDir()
361+
testFile := filepath.Join(tmpDir, "large-session.jsonl")
362+
363+
content := `{"type":"user","cwd":"/test","message":{"content":"hello"},"timestamp":"2024-01-15T10:00:00Z"}`
364+
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
365+
t.Fatalf("failed to write test file: %v", err)
366+
}
367+
368+
// maxSize of 10 bytes - file should be skipped
369+
conv, err := parseConversationFile(testFile, time.Time{}, 10)
370+
if err != nil {
371+
t.Fatalf("parseConversationFile failed: %v", err)
372+
}
373+
374+
if conv != nil {
375+
t.Error("parseConversationFile should return nil for files larger than maxSize")
376+
}
377+
}
378+

0 commit comments

Comments
 (0)