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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ External knowledge sources are configured with active `*.inputs.toml` manifests
- **B2 project fallback identity + historical re-resolution**: when the global registry (`~/.config/backscroll/projects.toml`) does not match a session's cwd, `projects.Identify()` derives a sanitized fallback id from the cwd basename (lowercase, `[a-z0-9-_]`; registry always wins when it matches). `rebuild` runs a re-resolution pass: `ReresolveProjects` decodes Claude's session-dir encoding (dashes-for-slashes; if the decoded path exists on disk its basename wins, else the last dash segment — the encoding is lossy and this heuristic is documented as ambiguous for dir names containing dashes) and relabels rows stuck at `project='unknown'`, returning distinct files resolved. Fallback labels are not revisited once set — followed by registry-aware re-resolution below.
- **B2.1 registry-aware re-resolution**: `projects.Identify()` now carries a `FromRegistry` flag to distinguish registry matches from fallback labels. `rebuild` includes a second phase that loads `~/.config/backscroll/projects.toml` and re-resolves historical sessions labeled with fallback IDs. For each session path, it decodes the cwd, applies cross-host normalization, and calls `Identify` with the registry. If a registry entry matches (FromRegistry=true) and differs from the stored fallback, the rows are updated. Only registry matches count — fallback-only paths are not re-labeled (no churn). This allows a future registry entry to correct historical misattribution without touching already-correct sessions. Wired into rebuild via `ReresolveProjectsWithRegistry` (queries.go).
- **B3 retroactive mining over stored text**: for sessions that expire from disk before Template/Correction/Sequence mining runs, `BackfillDerived()` recovers templates, correction signals, and lossy tool_events from stored text in search_items. Stale template re-mining (F2a) is integrated: BackfillDerived first discovers stale paths via `StaleTemplatePaths(CurrentNormalizationVersion)` and re-mines templates under v2 heuristics, updating existing templates to normalization_version=2. Template mining reuses `internal/templates/Miner` over tool-text rows. Correction detection filters input to prose only (role='user' AND content_type IN ('text','code')) to avoid tool_result false positives; lexicon, denial, and rephrase detectors run on prose; interrupt detector runs on all user messages. Lossy tool_events reverse-parse toolfmt input serialization (<ToolName> <key=val> ...; heuristic: first token has no '=', at least one subsequent has '=') to extract tool_name and command_head; outputs unattributable without tool_use_id so skipped; extraction_version=0 marks lossy rows (inputs only, unrecoverable once disk source expires). `rebuild` command now runs after mutation-class startup sync and then: (1) re-derives FTS from DB, (2) BackfillDerived (stale-path discovery + re-mining + expired-file discovery + batch mining + progress reporting), (3) re-resolves projects, and (4) applies registry-aware re-resolution. It performs no second sync. The recover path retains the remediation lock through post-install sync.
- **Early input validation**: CLI commands validate positional arguments and semantic flag values through Cobra `Args`, which runs before the root `PersistentPreRunE`; the root also checks required flags and flag groups before invoking the startup policy for the command class. Invalid invocations therefore fail before opening, creating, synchronizing, or recovering the database. Keep pure validators reusable from direct `run*` callers so tests and non-Cobra entry points preserve the same boundary.
- **Early input validation**: CLI commands validate positional arguments and semantic flag values through Cobra `Args`, which runs before the root `PersistentPreRunE`; the root also checks required flags and flag groups before invoking the startup policy for the command class. Invalid invocations therefore fail before opening, creating, synchronizing, or recovering the database. `internal/services` owns pure query validation as the shared CLI/MCP boundary. Direct service calls validate before database access. Search accepts limit 0 for the historical internal default of 100, or an explicit limit from 1 through 300. Context accepts max_tokens from 64 through 16384 and uses 2000 by default.
- **Coverage gate**: CI enforces ≥85% aggregate statement coverage via `go test ./... -race -coverprofile`. Local check: `bash scripts/check-coverage.sh`. Tests that depend on local machine state (e.g. `~/.config/backscroll/projects.toml`) must use `t.Setenv("HOME", tempDir)` to stay reproducible on CI. Likewise, `InputsDir` branches requiring `BACKSCROLL_CONFIG_DIR` to be unset must set it to `""` via `t.Setenv`. To test the `Validate` orphan path, insert directly into `search_items` without a matching `indexed_files` row.
- **Zero-result guidance**: when `search`/`list` return no rows, actionable suggestions (`--all-projects`, `--content-type tool`, `backscroll status`) are printed to STDERR — never STDOUT, so `--json` stays a clean empty payload.
- **Search robot output contract**: robot mode on search emits `result_N_field=value` lines exactly once-wrapped (the robot path writes lines directly; passing pre-formatted lines through the picokit formatter double-wraps them as `result_N=result_N_field=...`). Search robot string values escape backslash as `\\`, carriage return as `\r`, and newline as `\n`. Minimal fields use bounded snippets; full fields retain indexed content. Positive `--max-tokens` budgets count the complete escaped payload with one Picokit estimate, including omission metadata; never sum independently rounded line/result estimates. Truncation removes whole trailing results, and emits no output when even the omission record cannot fit.
Expand Down
54 changes: 22 additions & 32 deletions cmd/backscroll/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,10 @@ import (

const (
contextDefaultWindow = 5
contextMaxWindow = 50
contextMaxTextRunes = 4000
contextDefaultMaxTokens = 2000
contextMinMaxTokens = 64
contextMaxMaxTokens = 16384
contextDefaultMaxTokens = services.DefaultContextMaxTokens
contextMinMaxTokens = services.MinContextMaxTokens
contextMaxMaxTokens = services.MaxContextMaxTokens
)

type contextCommandOptions struct {
Expand Down Expand Up @@ -121,32 +120,29 @@ func (opts *contextCommandOptions) captureSelectorFlags(cmd *cobra.Command) {
}

func validateContextRequest(opts contextCommandOptions) error {
uuidSelector := opts.uuidSet && !opts.sourcePathSet && !opts.ordinalSet
pathOrdinalSelector := !opts.uuidSet && opts.sourcePathSet && opts.ordinalSet
if !uuidSelector && !pathOrdinalSelector {
return fmt.Errorf("provide exactly one anchor selector: --uuid or --source-path with --ordinal")
}
if uuidSelector && opts.uuid == "" {
return fmt.Errorf("--uuid must not be empty")
}
if pathOrdinalSelector && opts.sourcePath == "" {
return fmt.Errorf("--source-path must not be empty")
}
if opts.before < 0 || opts.before > contextMaxWindow {
return fmt.Errorf("--before must be between 0 and %d", contextMaxWindow)
}
if opts.after < 0 || opts.after > contextMaxWindow {
return fmt.Errorf("--after must be between 0 and %d", contextMaxWindow)
}
if opts.maxTokens < contextMinMaxTokens || opts.maxTokens > contextMaxMaxTokens {
return fmt.Errorf("--max-tokens must be between %d and %d", contextMinMaxTokens, contextMaxMaxTokens)
if err := services.ValidateContextRequest(contextServiceRequest(opts)); err != nil {
return err
}
if opts.jsonFormat && opts.robotFormat {
return fmt.Errorf("--json and --robot are mutually exclusive")
}
return nil
}

func contextServiceRequest(opts contextCommandOptions) services.ContextRequest {
request := services.ContextRequest{Before: opts.before, After: opts.after, MaxTokens: opts.maxTokens}
if opts.uuidSet {
request.UUID = &opts.uuid
}
if opts.sourcePathSet {
request.SourcePath = &opts.sourcePath
}
if opts.ordinalSet {
request.Ordinal = &opts.ordinal
}
return request
}

func runContextCommand(ctx context.Context, stdout, stderr io.Writer, cfg *config.Config, opts contextCommandOptions) (retErr error) {
if err := validateContextRequest(opts); err != nil {
return err
Expand All @@ -161,13 +157,7 @@ func runContextCommand(ctx context.Context, stdout, stderr io.Writer, cfg *confi
}
defer func() { retErr = closeIndexDB(db, retErr) }()

request := services.ContextRequest{Before: opts.before, After: opts.after}
if opts.uuidSet {
request.UUID = &opts.uuid
} else {
request.SourcePath = &opts.sourcePath
request.Ordinal = &opts.ordinal
}
request := contextServiceRequest(opts)
queryService := services.QueryService{DB: db}
response, err := queryService.Context(ctx, request)
if err != nil {
Expand All @@ -187,13 +177,13 @@ func runContextCommand(ctx context.Context, stdout, stderr io.Writer, cfg *confi
} else if opts.robotFormat {
format = contextRobotFormat
}
payload, successfulPayloadFits, err := contextSuccessfulPayloadWithinBudget(response.Records, format, opts.maxTokens)
payload, successfulPayloadFits, err := contextSuccessfulPayloadWithinBudget(response.Records, format, request.MaxTokens)
if err != nil {
return fmt.Errorf("format context records: %w", err)
}
if !successfulPayloadFits {
return writeContextDiagnostic(stdout, stderr, "context_budget_too_small",
fmt.Sprintf("--max-tokens %d cannot fit the anchor and required metadata", opts.maxTokens), opts)
fmt.Sprintf("--max-tokens %d cannot fit the anchor and required metadata", request.MaxTokens), opts)
}
if _, err := stdout.Write(payload); err != nil {
return fmt.Errorf("write context records: %w", err)
Expand Down
3 changes: 2 additions & 1 deletion cmd/backscroll/context_startup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ func TestInvalidRootContextDoesNotCreateDatabaseOrStartupSidecar(t *testing.T) {
{name: "empty path", args: []string{"context", "--source-path=", "--ordinal", "1"}},
{name: "invalid before", args: []string{"context", "--uuid", "u", "--before", "-1"}},
{name: "invalid after", args: []string{"context", "--uuid", "u", "--after", "51"}},
{name: "invalid token budget", args: []string{"context", "--uuid", "u", "--max-tokens", "63"}},
{name: "token budget below minimum", args: []string{"context", "--uuid", "u", "--max-tokens", "63"}},
{name: "token budget above maximum", args: []string{"context", "--uuid", "u", "--max-tokens", "16385"}},
{name: "conflicting formats", args: []string{"context", "--uuid", "u", "--json", "--robot"}},
}

Expand Down
67 changes: 67 additions & 0 deletions cmd/backscroll/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,73 @@ func TestContextTextAndRobotSuccessfulPayloadBudgetBoundaries(t *testing.T) {
}
}

func TestContextValidationGoldenErrorsAndOrder(t *testing.T) {
tests := []struct {
name string
opts contextCommandOptions
want string
}{
{
name: "selector wins",
opts: contextCommandOptions{before: -1, after: -1, maxTokens: 63},
want: "provide exactly one anchor selector: --uuid or --source-path with --ordinal",
},
{
name: "ambiguous selector",
opts: contextCommandOptions{uuid: "u", uuidSet: true, sourcePath: "/x", sourcePathSet: true, ordinalSet: true, maxTokens: contextDefaultMaxTokens},
want: "provide exactly one anchor selector: --uuid or --source-path with --ordinal",
},
{
name: "empty UUID wins",
opts: contextCommandOptions{uuidSet: true, before: -1, after: -1, maxTokens: 63},
want: "--uuid must not be empty",
},
{
name: "empty path wins",
opts: contextCommandOptions{sourcePathSet: true, ordinalSet: true, before: -1, after: -1, maxTokens: 63},
want: "--source-path must not be empty",
},
{
name: "before wins",
opts: contextCommandOptions{uuid: "u", uuidSet: true, before: 51, after: -1, maxTokens: 63},
want: "--before must be between 0 and 50",
},
{
name: "after wins",
opts: contextCommandOptions{uuid: "u", uuidSet: true, after: 51, maxTokens: 63},
want: "--after must be between 0 and 50",
},
{
name: "max tokens low",
opts: contextCommandOptions{uuid: "u", uuidSet: true, maxTokens: 63},
want: "--max-tokens must be between 64 and 16384",
},
{
name: "max tokens high",
opts: contextCommandOptions{uuid: "u", uuidSet: true, maxTokens: 16385},
want: "--max-tokens must be between 64 and 16384",
},
{
name: "format conflict follows shared validation",
opts: contextCommandOptions{uuid: "u", uuidSet: true, maxTokens: contextDefaultMaxTokens, jsonFormat: true, robotFormat: true},
want: "--json and --robot are mutually exclusive",
},
{
name: "minimum bounds",
opts: contextCommandOptions{uuid: "u", uuidSet: true, before: 0, after: 0, maxTokens: 64},
},
{
name: "maximum bounds",
opts: contextCommandOptions{sourcePath: "/x", sourcePathSet: true, ordinalSet: true, before: 50, after: 50, maxTokens: 16384},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assertCLIValidationError(t, validateContextRequest(test.opts), test.want)
})
}
}

func TestContextValidationLimitsAndSelectors(t *testing.T) {
validUUID := contextCommandOptions{uuid: "opaque", uuidSet: true, before: 0, after: 50, maxTokens: 64}
if err := validateContextRequest(validUUID); err != nil {
Expand Down
41 changes: 18 additions & 23 deletions cmd/backscroll/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,10 @@ protected. All scope filters remain fixed; no OR or semantic expansion is used.`
Args: func(cmd *cobra.Command, args []string) error {
return validateCommandBeforeStartup(cmd, args, cobra.MaximumNArgs(1), func() error {
query := searchQuery(text, args)
_, _, err := validateAndParseSearchRequest(query, fields, contentType, after, before)
_, _, err := validateAndParseSearchRequest(query, fields, contentType, after, before, limit, offset, relax)
if err != nil {
return err
}
if relax {
if err := storage.ValidateRelaxationQuery(query); err != nil {
return err
}
}
resolved, err := effectiveProject(project, allProjects)
if err == nil {
project = resolved
Expand Down Expand Up @@ -124,22 +119,18 @@ func searchQuery(text string, args []string) string {
return text
}

func validateAndParseSearchRequest(query, fields, contentType, after, before string) (*time.Time, *time.Time, error) {
if query == "" {
return nil, nil, fmt.Errorf("search query required (use --text <query> or positional argument)")
func validateAndParseSearchRequest(query, fields, contentType, after, before string, limit, offset int, relax bool) (*time.Time, *time.Time, error) {
request := services.SearchRequest{Query: query}
if err := services.ValidateSearchRequest(request); err != nil {
return nil, nil, err
}
if fields != "minimal" && fields != "full" {
return nil, nil, fmt.Errorf("invalid --fields value %q: must be minimal or full", fields)
}

validContentTypes := map[string]bool{
"text": true,
"code": true,
"tool": true,
"reasoning": true,
}
if contentType != "" && !validContentTypes[contentType] {
return nil, nil, fmt.Errorf("invalid --content-type %q; must be one of: text, code, tool, reasoning", contentType)
request.Options.ContentType = contentType
if err := services.ValidateSearchRequest(request); err != nil {
return nil, nil, err
}

var afterTime, beforeTime *time.Time
Expand All @@ -157,6 +148,15 @@ func validateAndParseSearchRequest(query, fields, contentType, after, before str
}
beforeTime = &parsed
}

request.Options.After = afterTime
request.Options.Before = beforeTime
request.Options.Limit = limit
request.Options.Offset = offset
request.Relax = relax
if err := services.ValidateSearchRequest(request); err != nil {
return nil, nil, err
}
return afterTime, beforeTime, nil
}

Expand All @@ -168,15 +168,10 @@ func runSearch(ctx context.Context, stdout, stderr io.Writer, cfg *config.Config
fields string, maxTokens int,
lexicalOnly bool, similarityThreshold float64, relax bool) (retErr error) {

afterTime, beforeTime, err := validateAndParseSearchRequest(query, fields, contentType, after, before)
afterTime, beforeTime, err := validateAndParseSearchRequest(query, fields, contentType, after, before, limit, offset, relax)
if err != nil {
return err
}
if relax {
if err := storage.ValidateRelaxationQuery(query); err != nil {
return err
}
}

project, err = effectiveProject(project, allProjects)
if err != nil {
Expand Down
Loading
Loading