Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 " ─────────────────────────────────────"
Expand Down
81 changes: 81 additions & 0 deletions docs/devlog/2026-08-03-content-search-noise.org
Original file line number Diff line number Diff line change
@@ -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 '<id>*.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 <id>= 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}' <file> | 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.
220 changes: 191 additions & 29 deletions internal/cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"sort"
"strings"
"text/tabwriter"
"unicode/utf8"

"github.com/spf13/cobra"

Expand All @@ -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,
}
Expand All @@ -46,6 +52,7 @@ var (
searchBefore string
searchModel string
searchContent bool
searchRaw bool
)

func init() {
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -165,28 +189,63 @@ 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,
})
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,
})
}
}
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading