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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ cmd/backscroll/
internal/
├── config/ — config resolution: backscroll.toml → ~/.config → env → defaults
├── compat/ — stateless schema-shape inspection, release lineage catalog, migration plans, and canonical recovery planning
├── directsearch/ — shared direct-search predicates (IsDirectSearchCommand, IsCodexDirectSearchCall) so readers ingest path and storage replay path use the exact same strings.Fields / argv-shape acceptance
├── input_config/ — input manifest loading, discovery, and legacy session-dirs compatibility
├── models/ — domain types: SessionRecord, MessageContent, ParsedFile, SearchResult, Stats
├── sync/ — WalkDir, SHA-256 dedup, JSONL parsing, noise filtering, content-type classification
Expand Down Expand Up @@ -235,6 +236,7 @@ Workflows delegate to [pablontiv/crossbeam](https://github.com/pablontiv/crossbe
github.com/pablontiv/backscroll/cmd/backscroll — CLI entrypoint
github.com/pablontiv/backscroll/internal/config — Config structs and resolution
github.com/pablontiv/backscroll/internal/compat — Stateless schema inspection, release lineage catalog, migration planning, and canonical recovery planning
github.com/pablontiv/backscroll/internal/directsearch — Shared direct-search predicates (IsDirectSearchCommand, IsCodexDirectSearchCall) used by readers and storage replay
github.com/pablontiv/backscroll/internal/input_config — Input manifest loading, discovery, and legacy session-dirs compatibility
github.com/pablontiv/backscroll/internal/models — Domain types and SearchEngine interface
github.com/pablontiv/backscroll/internal/sync — Session parsing and noise filtering
Expand Down
61 changes: 61 additions & 0 deletions internal/directsearch/directsearch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Package directsearch holds the shared predicates the Codex ingest path
// and the storage replay path both rely on to decide whether a stored tool
// call is a direct `backscroll search` invocation. Keeping a single source of
// truth here is the only way the SQL "what's pending requeue?" predicate and
// the reader's "did this row get marked?" predicate can stay in lockstep —
// every prior SQL GLOB attempt to enumerate separator byte-sequences missed at
// least one real encoding (Unicode whitespace that strings.Fields accepts but
// JSON escapes in non-uniform ways), so the replay check now decodes the JSON
// argv and re-runs the exact strings.Fields-based acceptance that ingest
// already uses.
package directsearch

import (
"encoding/json"
"path"
"strings"
)

// IsDirectSearchCommand is the one command boundary every reader shares: the
// raw command text must start with the bare `backscroll search` tokens.
// Absolute paths, env/shell wrappers, and other subcommands are not echoes.
// It mirrors strings.Fields splitting, so any unicode.IsSpace separator
// between the two tokens is accepted.
func IsDirectSearchCommand(command string) bool {
fields := strings.Fields(command)
return len(fields) >= 2 && fields[0] == "backscroll" && fields[1] == "search"
}

// IsCodexDirectSearchCall recognizes Codex's own direct shell invocations of
// `backscroll search`: an `exec_command` whose raw `cmd` starts with the bare
// tokens, or a `shell` call whose argv is exactly a shell, `-c`/`-lc`, and
// that same command string. `arguments` is the JSON object the codex rollout
// stored (e.g. `{"command":["sh","-c","backscroll search"]}` or
// `{"cmd":"backscroll search"}`).
func IsCodexDirectSearchCall(tool, arguments string) bool {
switch tool {
case "exec_command":
var obj struct {
Cmd string `json:"cmd"`
}
if json.Unmarshal([]byte(arguments), &obj) != nil {
return false
}
return IsDirectSearchCommand(obj.Cmd)
case "shell":
var obj struct {
Command []string `json:"command"`
}
if json.Unmarshal([]byte(arguments), &obj) != nil || len(obj.Command) != 3 {
return false
}
if !strings.HasSuffix(path.Base(obj.Command[0]), "sh") {
return false
}
if obj.Command[1] != "-c" && obj.Command[1] != "-lc" {
return false
}
return IsDirectSearchCommand(obj.Command[2])
}
return false
}
176 changes: 176 additions & 0 deletions internal/directsearch/directsearch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package directsearch

import "testing"

func TestIsDirectSearchCommand(t *testing.T) {
tests := []struct {
name, command string
want bool
}{
{"bare", "backscroll search", true},
{"with_flags", "backscroll search --text orchard", true},
{"only_two_tokens", "backscroll search", true},
{"missing_search", "backscroll", false},
{"missing_backscroll", "search", false},
{"empty", "", false},
{"whitespace_separated", "backscroll\tsearch", true},
{"newline_separated", "backscroll\nsearch", true},
{"nbsp_separated", "backscroll\u00a0search", true},
{"em_space_separated", "backscroll\u2003search", true},
{"wrapped_in_another", "env backscroll search", false}, // Fields[0]="env" not "backscroll"
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsDirectSearchCommand(tt.command); got != tt.want {
t.Errorf("IsDirectSearchCommand(%q) = %v, want %v", tt.command, got, tt.want)
}
})
}
}

func TestIsCodexDirectSearchCall_Shell(t *testing.T) {
tests := []struct {
name, tool, arguments string
want bool
}{
{
name: "bare_three_element_c",
tool: "shell",
arguments: `{"command":["sh","-c","backscroll search"]}`,
want: true,
},
{
name: "three_element_with_extra_flags",
tool: "shell",
arguments: `{"command":["sh","-c","backscroll search --text orchard"]}`,
want: true,
},
{
name: "three_element_lc",
tool: "shell",
arguments: `{"command":["bash","-lc","backscroll search --text orchard"]}`,
want: true,
},
{
name: "shell_binary_path",
tool: "shell",
arguments: `{"command":["/bin/bash","-c","backscroll search"]}`,
want: true,
},
{
name: "four_elements_rejected",
tool: "shell",
arguments: `{"command":["sh","-c","backscroll search","extra"]}`,
want: false,
},
{
name: "wrong_flag",
tool: "shell",
arguments: `{"command":["sh","-x","backscroll search"]}`,
want: false,
},
{
name: "different_command",
tool: "shell",
arguments: `{"command":["sh","-c","ls"]}`,
want: false,
},
{
name: "not_a_shell_binary",
tool: "shell",
arguments: `{"command":["python","-c","backscroll search"]}`,
want: false,
},
{
name: "arguments_extra_fields_decoded",
tool: "shell",
arguments: `{"additional_permissions":{"network":false},"command":["bash","-lc","backscroll search"]}`,
want: true,
},
{
name: "arguments_extra_fields_in_storage_position",
tool: "shell",
arguments: `{"command":["sh","-c","backscroll search"],"workdir":"/tmp","timeout_ms":10000}`,
want: true,
},
{
name: "malformed_arguments",
tool: "shell",
arguments: `not json`,
want: false,
},
{
name: "command_separator_tab_accepted",
tool: "shell",
arguments: `{"command":["sh","-c","backscroll\tsearch"]}`,
want: true,
},
{
name: "command_separator_nbsp_accepted",
tool: "shell",
arguments: `{"command":["sh","-c","backscroll\u00a0search"]}`,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsCodexDirectSearchCall(tt.tool, tt.arguments); got != tt.want {
t.Errorf("IsCodexDirectSearchCall(%q, %q) = %v, want %v", tt.tool, tt.arguments, got, tt.want)
}
})
}
}

func TestIsCodexDirectSearchCall_ExecCommand(t *testing.T) {
tests := []struct {
name, tool, arguments string
want bool
}{
{
name: "bare",
tool: "exec_command",
arguments: `{"cmd":"backscroll search --text orchard"}`,
want: true,
},
{
name: "no_args",
tool: "exec_command",
arguments: `{"cmd":"backscroll search"}`,
want: true,
},
{
name: "different_command",
tool: "exec_command",
arguments: `{"cmd":"rg orchard"}`,
want: false,
},
{
name: "malformed_arguments",
tool: "exec_command",
arguments: `not json`,
want: false,
},
{
name: "missing_cmd_key",
tool: "exec_command",
arguments: `{}`,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsCodexDirectSearchCall(tt.tool, tt.arguments); got != tt.want {
t.Errorf("IsCodexDirectSearchCall(%q, %q) = %v, want %v", tt.tool, tt.arguments, got, tt.want)
}
})
}
}

func TestIsCodexDirectSearchCall_UnknownTool(t *testing.T) {
if got := IsCodexDirectSearchCall("unknown", `{"command":["sh","-c","backscroll search"]}`); got {
t.Errorf("unknown tool should not match, got true")
}
if got := IsCodexDirectSearchCall("", `{"command":["sh","-c","backscroll search"]}`); got {
t.Errorf("empty tool should not match, got true")
}
}
11 changes: 2 additions & 9 deletions internal/readers/claude_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"time"

"github.com/pablontiv/backscroll/internal/directsearch"
"github.com/pablontiv/backscroll/internal/input_config"
"github.com/pablontiv/backscroll/internal/models"
"github.com/pablontiv/backscroll/internal/sync"
Expand Down Expand Up @@ -250,15 +251,7 @@ func isDirectSearchInput(tool string, input json.RawMessage) bool {
if err := json.Unmarshal(input, &obj); err != nil {
return false
}
return isDirectSearchCommand(obj.Command)
}

// isDirectSearchCommand is the one command boundary every reader shares: the
// raw command text must start with the bare `backscroll search` tokens.
// Absolute paths, env/shell wrappers, and other subcommands are not echoes.
func isDirectSearchCommand(command string) bool {
fields := strings.Fields(command)
return len(fields) >= 2 && fields[0] == "backscroll" && fields[1] == "search"
return directsearch.IsDirectSearchCommand(obj.Command)
}

func classifyText(text string) string {
Expand Down
36 changes: 5 additions & 31 deletions internal/readers/codex_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package readers

import (
"encoding/json"
"path"
"strings"
"time"

"github.com/pablontiv/backscroll/internal/directsearch"
"github.com/pablontiv/backscroll/internal/input_config"
"github.com/pablontiv/backscroll/internal/models"
"github.com/pablontiv/backscroll/internal/sync"
Expand Down Expand Up @@ -125,37 +125,11 @@ func codexCallOf(item codexItem) codexCall {
return codexCall{}
}

// isCodexDirectSearchCall recognizes Codex's own direct shell invocations of
// `backscroll search`: an `exec_command` whose raw `cmd` starts with the bare
// tokens, or a `shell` call whose argv is exactly a shell, `-c`/`-lc`, and
// that same command string. The wrapper form is Codex-reader-local; the
// shared command boundary itself never widens.
// isCodexDirectSearchCall is now a thin wrapper around the shared predicate
// in internal/directsearch. The storage replay path uses the same function,
// so the two call sites cannot drift apart.
func isCodexDirectSearchCall(tool, arguments string) bool {
switch tool {
case "exec_command":
var obj struct {
Cmd string `json:"cmd"`
}
if json.Unmarshal([]byte(arguments), &obj) != nil {
return false
}
return isDirectSearchCommand(obj.Cmd)
case "shell":
var obj struct {
Command []string `json:"command"`
}
if json.Unmarshal([]byte(arguments), &obj) != nil || len(obj.Command) != 3 {
return false
}
if !strings.HasSuffix(path.Base(obj.Command[0]), "sh") {
return false
}
if obj.Command[1] != "-c" && obj.Command[1] != "-lc" {
return false
}
return isDirectSearchCommand(obj.Command[2])
}
return false
return directsearch.IsCodexDirectSearchCall(tool, arguments)
}

func codexMessage(item codexItem, ts time.Time, reasoning bool) (models.Message, bool) {
Expand Down
Loading
Loading