diff --git a/CHANGELOG.md b/CHANGELOG.md index aea48c5..0579188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Changed +- **`ccx search --content` now ranks conversation, not boilerplate.** The v0.13.0 raw-line scan counted every transcript line, so injected noise dominated ranking: a Stop-hook line fired every turn put a 327-hit session first while the session where the topic was actually designed ranked #3 (docs/devlog/2026-08-03-content-search-noise.org). The default now parses each candidate session (all providers, sidechains included) and counts only conversation text — user prompts and assistant text/thinking; hook attachments, tool results, command echoes, and meta lines contribute zero. New `--raw` keeps the old behavior verbatim: grep parity over raw lines, no parse, misses nothing grep would find. A cheap raw-line prefilter keeps the default at par with `--raw` speed (only hit files pay the parse); queries whose bytes JSON-escaping could hide (`"`, `\`, non-ASCII) skip the prefilter for correctness. + +### Added +- **Content results show what matched and where the file lives.** Each `--content` result now carries a role-labeled matched-text snippet (`[user]`/`[assistant]`/`[agent]`) in the table, and `--json` gains `path` (session/content results) plus up to 3 `previews` — noise is distinguishable from signal, and drill-down no longer needs `find` + `grep` outside ccx. + +### Fixed +- **`make build` refreshes a stale root `./ccx`.** A bare `go build ./cmd/ccx` drops `./ccx` at the repo root where `.gitignore` hides it; `make build` writes `bin/ccx`, so the root copy silently went stale and shadowed fresh builds ("built unknown"). `make build` now overwrites the root copy when one exists. + ## [0.13.0] - 2026-08-02 ### Added diff --git a/Makefile b/Makefile index 826196a..b0684f0 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,10 @@ GOBIN := $(or $(shell go env GOBIN),$(shell go env GOPATH)/bin) # Default: build for current OS/arch build: @go build $(LDFLAGS) -o bin/ccx ./cmd/ccx + @# A bare `go build ./cmd/ccx` drops ./ccx at the repo root and + @# .gitignore hides it; refresh it so a stale copy can't shadow + @# this build. + @if [ -f ccx ]; then cp -f bin/ccx ccx; fi @echo "" @echo " ccx built successfully" @echo " ─────────────────────────────────────" diff --git a/docs/devlog/2026-08-03-content-search-noise.org b/docs/devlog/2026-08-03-content-search-noise.org new file mode 100644 index 0000000..8b3b2f7 --- /dev/null +++ b/docs/devlog/2026-08-03-content-search-noise.org @@ -0,0 +1,81 @@ +* [2026-08-03] Dev Log: Dogfood findings — content search noise :SEARCH: + +** Context +Topic-mining session: find where "deadman" / "auto-handoff" were +actually discussed across the session store, using the fresh +integration build (9bbbab3). The answer was findable, but only by +dropping out of ccx to raw grep — the exact fallback =search +--content= was built to remove. Findings recorded, not fixed. + +** Findings +1. Hit-count ranking is noise-dominated. =search --content deadman= + ranks a session with 327 hits first; classification of the raw + file showed 100% of those hits are Stop-hook boilerplate + ("deadman armed: auto-handoff in 50m..." fired every turn as a + =hook_system_message= attachment). The session where deadman was + actually designed and shipped (ac342f0d, deva-chore) ranked #3. + "deadman" and "auto-handoff" return near-identical result lists + because the same injected sentence contains both terms. A search + that ranks boilerplate above the answer fails its one job. +2. No matched-line preview. Results show hit count + session summary + only; noise is indistinguishable from signal without leaving ccx. + Repro: the 327-hit result and the 13-hit real-discussion result + look the same modulo the number. +3. Results don't carry the session file path — even =--json= (only + type/project/session/summary/time/matches). Drill-down meant + =find ~/.claude/projects -name '*.jsonl'= + =/usr/bin/grep=, + losing all of ccx's provider abstraction. +4. search/view can't cross-verify: search reports 327 hits in + e1a0fa78; =ccx view e1a0fa78= renders zero occurrences (hits live + in hook attachments and raw tool-result lines that view never + renders). No in-ccx way to see what a content hit matched. +5. Stale-binary trap: =make build= writes =bin/ccx=, but a previous + build sits at repo root as =./ccx= and README/help examples use + =./ccx=. First run picked up yesterday's binary ("built + unknown"). One canonical output path, or have =make build= + refresh both. + +** Fix directions (for the FTS follow-up issue) +- The planned FTS index issue (HANDOFF Next #1, plugs in at + internal/cmd/search.go:countContentMatches) should cover relevance, + not just speed: a scan that defaults to user/assistant text and + gates attachments/tool-results/hook lines behind =--raw= would have + put ac342f0d at #1 for this query. +- Matched-line previews (grep =-m 3= style) per result; include + =path= in =--json=. +- A drill-down that stays inside ccx, e.g. =view --grep= or + =search --content --show = printing matching lines with role + labels. + +** Resolution [2026-08-03] +Findings 1-3 and 5 adopted and shipped on feat/search-content-signal; +finding 4 (in-ccx drill-down) and the FTS index stay as follow-up +issues. +- =CHANGE= --content default now parses candidate sessions and counts + only conversation text (KindUserPrompt/KindAssistant text+thinking + blocks; sidechains included, role-labeled =agent=). Hook + attachments, tool results, command echoes, meta: zero. New =--raw= + is the old scan verbatim (implies --content). +- =FEAT= role-labeled matched-text preview per result; =--json= gains + =path= (session+content) and =previews= (max 3). +- =FIX= =make build= refreshes a stale root =./ccx= when present. +- Perf: cheap raw-line prefilter gates the parse; only hit files pay + it. Skipped when the query contains bytes JSON escaping could hide + (=\"=, =\\=, non-ASCII) — a zero-hit raw scan proves nothing there. +- Acceptance rerun on the live store: =search --content deadman= now + ranks ac342f0d #1 (27 hits, preview shows the naming verdict); the + boilerplate session e1a0fa78 (330 raw hits) is absent from default + output and still #1 under =--raw=. Timing at par: 20s signal vs 23s + raw. The ~20s floor is the crawl itself — FTS issue's territory. +- Decision: the "grep parity by design" default (v0.13.0, 428defd) + lost its first real dogfood test — parity moved to =--raw=, + relevance became the default. Recall is preserved, just not silent: + help text says what each mode reads. + +** Notes +- Evidence classification one-liner: + =/usr/bin/grep -o -E '.{60}deadman.{60}' | sort | uniq -c | + sort -rn= — 109x "deadman armed" attachment + 109x its stdout twin + accounted for the top session's count. +- Prior friction round (2026-08-02) is fully closed: PR #32 merged in + v0.13.0. These are new, post-release findings. diff --git a/internal/cmd/search.go b/internal/cmd/search.go index 8ee0412..e8f84fe 100644 --- a/internal/cmd/search.go +++ b/internal/cmd/search.go @@ -10,6 +10,7 @@ import ( "sort" "strings" "text/tabwriter" + "unicode/utf8" "github.com/spf13/cobra" @@ -23,16 +24,21 @@ var searchCmd = &cobra.Command{ Short: "Search across projects and sessions", Long: `Search for projects and sessions by name or summary. -With --content, also scan transcript lines inside session files -(including subagent files) — grep parity, but with session identity, -provider abstraction, and date filters. +With --content, also scan conversation text inside session files +(including subagent files): user prompts and assistant replies, +ranked by hit count with a matched-text preview. Injected noise — +tool results, hook attachments, command echoes — doesn't count. + +Add --raw to match every raw transcript line instead: grep parity, +no parse, misses nothing grep would find. Examples: - ccx search auth # Find sessions about authentication - ccx search myproject # Find project by name - ccx search "fix bug" # Multi-word search - ccx search -t session # Only search sessions - ccx search --content goose # Scan message content (slower)`, + ccx search auth # Find sessions about authentication + ccx search myproject # Find project by name + ccx search "fix bug" # Multi-word search + ccx search -t session # Only search sessions + ccx search --content goose # Scan conversation text (slower) + ccx search --raw goose # Grep parity over raw lines`, Args: cobra.MinimumNArgs(1), RunE: runSearch, } @@ -46,6 +52,7 @@ var ( searchBefore string searchModel string searchContent bool + searchRaw bool ) func init() { @@ -56,25 +63,41 @@ func init() { searchCmd.Flags().StringVar(&searchAfter, "after", "", "sessions after date (YYYY-MM-DD)") searchCmd.Flags().StringVar(&searchBefore, "before", "", "sessions before date (YYYY-MM-DD)") searchCmd.Flags().StringVar(&searchModel, "model", "", "filter by model name substring") - searchCmd.Flags().BoolVar(&searchContent, "content", false, "also scan message content in session files (slower)") + searchCmd.Flags().BoolVar(&searchContent, "content", false, "also scan conversation text in session files (slower)") + searchCmd.Flags().BoolVar(&searchRaw, "raw", false, "content scan matches every raw transcript line (grep parity; implies --content)") rootCmd.AddCommand(searchCmd) } type searchResult struct { - Type string `json:"type"` - Project string `json:"project"` - Session string `json:"session,omitempty"` - Summary string `json:"summary"` - Time string `json:"time,omitempty"` - Matches int `json:"matches,omitempty"` - Priority int `json:"-"` + Type string `json:"type"` + Project string `json:"project"` + Session string `json:"session,omitempty"` + Path string `json:"path,omitempty"` + Summary string `json:"summary"` + Time string `json:"time,omitempty"` + Matches int `json:"matches,omitempty"` + Previews []contentPreview `json:"previews,omitempty"` + Priority int `json:"-"` +} + +// contentPreview is one matched conversation snippet, role-labeled so +// noise is distinguishable from signal without leaving ccx. +type contentPreview struct { + Role string `json:"role"` // "user" | "assistant" | "agent" + Text string `json:"text"` } +const maxContentPreviews = 3 + func runSearch(cmd *cobra.Command, args []string) error { query := strings.ToLower(strings.Join(args, " ")) backend := provider.Default() + if searchRaw { + searchContent = true + } + after, err := config.ParseDate(searchAfter) if err != nil { return fmt.Errorf("invalid --after date: %w", err) @@ -152,6 +175,7 @@ func runSearch(cmd *cobra.Command, args []string) error { Type: "session", Project: projDisplay, Session: truncateID(s.ID, 8), + Path: s.FilePath, Summary: sessionSummaryPreview(s.Summary, 64), Time: formatAge(s.StartTime), Priority: 0, @@ -165,6 +189,7 @@ func runSearch(cmd *cobra.Command, args []string) error { Type: "session", Project: projDisplay, Session: truncateID(s.ID, 8), + Path: s.FilePath, Summary: sessionSummaryPreview(s.Summary, 64), Time: formatAge(s.StartTime), Priority: 2, @@ -172,21 +197,55 @@ func runSearch(cmd *cobra.Command, args []string) error { continue } - // Content scan: raw transcript lines, main file plus subagent - // files. Grep parity by design — no parse, so it works for - // every provider's format and misses nothing grep would find. + // Content scan. The default counts conversation text only — + // user prompts and assistant replies — so ranking follows + // discussion, not injected boilerplate (a hook line fired + // every turn once outranked the real answer 327 hits to 13; + // docs/devlog/2026-08-03-content-search-noise.org). --raw + // keeps grep parity over raw transcript lines, main file + // plus subagent files: no parse, works for every provider's + // format, misses nothing grep would find. if searchContent { - if n := countContentMatches(s.FilePath, query); n > 0 { - results = append(results, searchResult{ - Type: "content", - Project: projDisplay, - Session: truncateID(s.ID, 8), - Summary: fmt.Sprintf("%d hits · %s", n, sessionSummaryPreview(s.Summary, 48)), - Time: formatAge(s.StartTime), - Matches: n, - Priority: 3, - }) + if searchRaw { + if n := countContentMatches(s.FilePath, query); n > 0 { + results = append(results, searchResult{ + Type: "content", + Project: projDisplay, + Session: truncateID(s.ID, 8), + Path: s.FilePath, + Summary: fmt.Sprintf("%d hits · %s", n, sessionSummaryPreview(s.Summary, 48)), + Time: formatAge(s.StartTime), + Matches: n, + Priority: 3, + }) + } + continue + } + + // Cheap line-scan prefilter before the full parse; only + // trustworthy when JSON escaping can't hide the query. + if rawPrefilterSafe(query) && countContentMatches(s.FilePath, query) == 0 { + continue } + n, previews := scanConversationText(backend, s.FilePath, query) + if n == 0 { + continue + } + summary := fmt.Sprintf("%d hits · %s", n, sessionSummaryPreview(s.Summary, 48)) + if len(previews) > 0 { + summary = fmt.Sprintf("%d hits · [%s] %s", n, previews[0].Role, truncateDisplay(previews[0].Text, 56)) + } + results = append(results, searchResult{ + Type: "content", + Project: projDisplay, + Session: truncateID(s.ID, 8), + Path: s.FilePath, + Summary: summary, + Time: formatAge(s.StartTime), + Matches: n, + Previews: previews, + Priority: 3, + }) } } } @@ -304,6 +363,109 @@ func truncateID(id string, max int) string { return id[:max] } +// sessionParser is the slice of provider.Backend the conversation +// scan needs; narrowed so tests can stub it. +type sessionParser interface { + ParseSession(filePath string) (*parser.Session, error) +} + +// scanConversationText parses one session (the parser loads sidechain +// files too) and searches only conversation text: text and thinking +// blocks of user prompts and assistant messages. Tool results, hook +// attachments, command echoes, and meta lines never count — that's +// what --raw is for. Returns total occurrences plus up to +// maxContentPreviews role-labeled snippets around the earliest +// matches. query must already be lowercase. +func scanConversationText(p sessionParser, path, query string) (int, []contentPreview) { + sess, err := p.ParseSession(path) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: skipping unparseable %s: %v\n", filepath.Base(path), err) + return 0, nil + } + + count := 0 + var previews []contentPreview + var walk func(msgs []*parser.Message) + walk = func(msgs []*parser.Message) { + for _, m := range msgs { + if m.Kind == parser.KindUserPrompt || m.Kind == parser.KindAssistant { + role := m.Type + if m.IsSidechain { + role = "agent" + } + for _, b := range m.Content { + if b.Type != "text" && b.Type != "thinking" { + continue + } + lower := strings.ToLower(b.Text) + n := strings.Count(lower, query) + if n == 0 { + continue + } + count += n + if len(previews) < maxContentPreviews { + previews = append(previews, contentPreview{ + Role: role, + Text: matchSnippet(b.Text, strings.Index(lower, query), len(query)), + }) + } + } + } + walk(m.Children) + } + } + walk(sess.RootMessages) + return count, previews +} + +// matchSnippet cuts a display window around a match, clamped to rune +// boundaries. idx indexes the lowered copy of text; byte positions can +// drift on the rare rune whose lowercase form changes width, so bounds +// are clamped rather than trusted. +func matchSnippet(text string, idx, qlen int) string { + if idx < 0 { + idx = 0 + } + if idx > len(text) { + idx = len(text) + } + start := idx - 32 + if start < 0 { + start = 0 + } + end := idx + qlen + 56 + if end > len(text) { + end = len(text) + } + for start > 0 && !utf8.RuneStart(text[start]) { + start-- + } + for end < len(text) && !utf8.RuneStart(text[end]) { + end++ + } + out := cleanDisplayText(text[start:end]) + if start > 0 { + out = "..." + out + } + if end < len(text) { + out += "..." + } + return out +} + +// rawPrefilterSafe reports whether a zero-hit raw line scan proves a +// zero-hit conversation scan. JSON writers escape `"`, `\`, control +// chars, and sometimes non-ASCII, so those queries must skip the +// cheap prefilter and parse every candidate session instead. +func rawPrefilterSafe(query string) bool { + for i := 0; i < len(query); i++ { + if query[i] < 0x20 || query[i] > 0x7e || query[i] == '"' || query[i] == '\\' { + return false + } + } + return true +} + // countContentMatches counts transcript lines containing query across // the main session file and any subagent files beside it (layout // knowledge lives in parser.SubagentFiles; providers without subagent diff --git a/internal/cmd/search_test.go b/internal/cmd/search_test.go index 3ffaee0..90324bc 100644 --- a/internal/cmd/search_test.go +++ b/internal/cmd/search_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/thevibeworks/ccx/internal/parser" ) func TestCountMatchingLines(t *testing.T) { @@ -64,6 +66,119 @@ func TestCountContentMatchesIncludesSubagents(t *testing.T) { } } +type stubSessionParser struct{} + +func (stubSessionParser) ParseSession(path string) (*parser.Session, error) { + return parser.ParseSession(path) +} + +// scanConversationText must count only what a human reads in the +// conversation. Hook attachments (isMeta) and tool-result lines are +// exactly the boilerplate that once outranked the real discussion +// 327 hits to 13 — they must contribute zero. +func TestScanConversationTextSignalOnly(t *testing.T) { + dir := t.TempDir() + main := filepath.Join(dir, "abc-123.jsonl") + lines := strings.Join([]string{ + `{"type":"user","uuid":"u1","timestamp":"2026-08-03T00:00:00Z","message":{"role":"user","content":[{"type":"text","text":"let's design the deadman auto-handoff timer"}]}}`, + `{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2026-08-03T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"deadman fires after idle; the deadman then writes the handoff"}]}}`, + `{"type":"user","uuid":"m1","parentUuid":"a1","isMeta":true,"timestamp":"2026-08-03T00:00:02Z","message":{"role":"user","content":[{"type":"text","text":"deadman armed: auto-handoff in 50m"}]}}`, + `{"type":"user","uuid":"t1","parentUuid":"m1","timestamp":"2026-08-03T00:00:03Z","message":{"role":"user","content":[{"type":"tool_result","content":"deadman armed: auto-handoff stdout twin"}]}}`, + `{"type":"assistant","uuid":"a2","parentUuid":"t1","timestamp":"2026-08-03T00:00:04Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"the deadman design needs a disarm path"}]}}`, + }, "\n") + "\n" + if err := os.WriteFile(main, []byte(lines), 0o644); err != nil { + t.Fatal(err) + } + subDir := filepath.Join(dir, "abc-123", "subagents") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + side := `{"type":"assistant","uuid":"s1","isSidechain":true,"timestamp":"2026-08-03T00:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"deadman in sidechain"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(subDir, "agent-1.jsonl"), []byte(side), 0o644); err != nil { + t.Fatal(err) + } + + // 6 raw lines match; only 5 occurrences live in conversation text + // (1 user + 2 assistant + 1 thinking + 1 sidechain). + if raw := countContentMatches(main, "deadman"); raw != 6 { + t.Fatalf("raw line matches: got %d, want 6", raw) + } + n, previews := scanConversationText(stubSessionParser{}, main, "deadman") + if n != 5 { + t.Fatalf("signal matches: got %d, want 5 (noise counted?)", n) + } + if len(previews) != maxContentPreviews { + t.Fatalf("previews: got %d, want %d", len(previews), maxContentPreviews) + } + if previews[0].Role != "user" || !strings.Contains(previews[0].Text, "deadman") { + t.Fatalf("first preview should be the user prompt, got [%s] %q", previews[0].Role, previews[0].Text) + } + if previews[1].Role != "assistant" { + t.Fatalf("second preview role: got %q, want assistant", previews[1].Role) + } + + if n, _ := scanConversationText(stubSessionParser{}, main, "auto-handoff"); n != 1 { + t.Fatalf("auto-handoff signal matches: got %d, want 1 (hook noise counted?)", n) + } +} + +func TestScanConversationTextSidechainRole(t *testing.T) { + dir := t.TempDir() + main := filepath.Join(dir, "abc-123.jsonl") + line := `{"type":"user","uuid":"u1","timestamp":"2026-08-03T00:00:00Z","message":{"role":"user","content":[{"type":"text","text":"kick off"}]}}` + "\n" + if err := os.WriteFile(main, []byte(line), 0o644); err != nil { + t.Fatal(err) + } + subDir := filepath.Join(dir, "abc-123", "subagents") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + side := `{"type":"assistant","uuid":"s1","isSidechain":true,"timestamp":"2026-08-03T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"goose only lives here"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(subDir, "agent-1.jsonl"), []byte(side), 0o644); err != nil { + t.Fatal(err) + } + + n, previews := scanConversationText(stubSessionParser{}, main, "goose") + if n != 1 || len(previews) != 1 { + t.Fatalf("sidechain match: got n=%d previews=%d, want 1/1", n, len(previews)) + } + if previews[0].Role != "agent" { + t.Fatalf("sidechain preview role: got %q, want agent", previews[0].Role) + } +} + +func TestMatchSnippet(t *testing.T) { + long := strings.Repeat("a", 100) + " needle " + strings.Repeat("b", 100) + got := matchSnippet(long, 101, len("needle")) + if !strings.Contains(got, "needle") { + t.Fatalf("snippet must contain the match, got %q", got) + } + if !strings.HasPrefix(got, "...") || !strings.HasSuffix(got, "...") { + t.Fatalf("mid-text snippet should be marked truncated on both ends, got %q", got) + } + if got := matchSnippet("short text", 0, 5); got != "short text" { + t.Fatalf("untruncated snippet: got %q", got) + } + // Out-of-range indexes must clamp, not panic. + _ = matchSnippet("tiny", 999, 4) +} + +func TestRawPrefilterSafe(t *testing.T) { + cases := map[string]bool{ + "deadman": true, + "fix bug": true, + `say "hi"`: false, + `back\slash`: false, + "路径": false, + "tab\there": false, + } + for q, want := range cases { + if got := rawPrefilterSafe(q); got != want { + t.Errorf("rawPrefilterSafe(%q) = %v, want %v", q, got, want) + } + } +} + // Grep parity must survive lines larger than any fixed scanner budget // (transcript lines with embedded images run past 10MB). func TestCountMatchingLinesOversizedLine(t *testing.T) {