Skip to content

Commit faf18a5

Browse files
committed
Add RedisSearch BM25 workspace query
1 parent 6a9d322 commit faf18a5

76 files changed

Lines changed: 5630 additions & 563 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,24 @@ The most important implementation seams are:
235235
- Control-plane import/checkpoint JSON fields typed as `map[string][]byte`
236236
expect base64-encoded string values. Small synthetic manifests can avoid that
237237
by using `ManifestEntry.inline`, which is already base64 text.
238+
- `afs status` can show running mounts from multiple product modes/databases.
239+
When a command scopes to the current config, explain when a status-visible
240+
mount belongs to another config instead of returning a bare "does not exist".
241+
- Top-level filesystem shortcuts like `afs grep` and `afs query` must route
242+
through the `afs fs` command path. Do not send them to older local-only
243+
helpers, or Cloud/Self-managed users will see local Redis errors instead of
244+
control-plane behavior.
245+
- Top-level filesystem shortcut help must say shortcuts use the "default"
246+
workspace and that explicit targeting requires `afs fs <workspace> <command>`.
247+
- Sync mount cold-start in Cloud-managed mode must hydrate from the workspace
248+
session's storage key/head checkpoint. Do not require direct Redis workspace
249+
metadata lookup by display name; cloud session Redis may expose the live root
250+
while metadata lives behind the control plane.
251+
- Preserve the two-command search UX: `grep` is exact text evidence and `query`
252+
is the powerful ranked retrieval command. `query` defaults to hybrid + rerank,
253+
supports `lex:`, `vec:`, `hyde:`, and `intent:` documents, and uses
254+
`--keyword` / `--semantic` for narrower modes. Do not reintroduce public
255+
`search` or `vsearch` commands unless the product direction changes again.
256+
- Workspace file/query CLI calls use resolved workspace routes under
257+
`/v1/workspaces/<id>/...`; when adding a scoped database route, add the
258+
matching resolved route and a regression test for workspace IDs.

README.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,53 @@ The important pattern is:
8383

8484
So AFS is not trying to beat your local SSD, and it is not trying to out-POSIX mature shared filesystems. It is trying to give you a remote, checkpointable, forkable workspace with enough performance that normal tools still feel normal.
8585

86+
## RedisSearch BM25 Query
87+
88+
AFS workspaces are not just mounted folders. They are now queryable knowledge
89+
surfaces backed by RedisSearch BM25.
90+
91+
When you mount a workspace, text files flow into the live Redis-backed
92+
workspace. AFS builds a RedisSearch chunk index over Markdown, JSON, JSONL,
93+
source files, config files, notes, logs, and other text content. Then
94+
`afs query` ranks the most relevant chunks with BM25:
95+
96+
```bash
97+
afs query "how do checkpoints work?"
98+
afs query "redis connection refused during setup"
99+
afs query "where is auth token configuration handled?"
100+
afs query "ralph loops"
101+
```
102+
103+
Example output:
104+
105+
```text
106+
#1 /docs/checkpoints.md:12-20 score 0.42
107+
Checkpoints are explicit snapshots of workspace state. File edits update the
108+
live workspace immediately, and checkpoint_create records a recoverable point.
109+
110+
#2 /README.md:148-156 score 0.31
111+
grep is for exact text evidence. query is for ranked conceptual search...
112+
```
113+
114+
This is useful for agents because they often know what they are looking for
115+
conceptually, but not the exact phrase or filename. `grep` is still the right
116+
tool for exact evidence. `query` is the right tool when an agent needs ranked
117+
context from docs, history, config, and source files.
118+
119+
The path is:
120+
121+
```text
122+
mounted folder -> AFS live workspace -> RedisSearch chunk index -> BM25 ranked results
123+
```
124+
125+
If RedisSearch is unavailable or the projection is temporarily stale, AFS falls
126+
back to direct keyword ranking over the workspace content. Use `--explain` to
127+
see which backend answered a query:
128+
129+
```bash
130+
afs query --explain --json "ralph loops"
131+
```
132+
86133
## Requirements
87134

88135
AFS requires a Redis instance you provide.
@@ -142,6 +189,19 @@ If you want to save a known-good point:
142189
./afs cp create my-repo before-refactor
143190
```
144191

192+
If you want to search workspace contents:
193+
194+
```bash
195+
./afs fs my-repo grep "DirtyHint"
196+
./afs fs my-repo query "how do checkpoints work?"
197+
./afs fs my-repo query --keyword "checkpoint savepoint"
198+
```
199+
200+
`grep` is for exact text evidence. `query` is for ranked conceptual search and
201+
falls back to keyword-ranked results when embeddings are disabled or
202+
unavailable. Keyword ranking uses RedisSearch BM25 query chunks when available,
203+
then falls back to direct content ranking.
204+
145205
If you want commands with an optional workspace argument to use `my-repo` by
146206
default:
147207

@@ -224,8 +284,9 @@ client on demand. A minimal config looks like:
224284
}
225285
```
226286

227-
The MCP surface is workspace-oriented: list/create/fork workspaces,
228-
read and edit files, grep a workspace, and create or restore checkpoints.
287+
The MCP surface is workspace-oriented: list/create/fork workspaces, read and
288+
edit files, grep exact text with `file_grep`, run ranked conceptual search
289+
with `file_query`, and create or restore checkpoints.
229290
File-edit tools update the live workspace state and leave the workspace dirty
230291
until `checkpoint_create` is called explicitly.
231292

cmd/afs/afs_commands.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1111,7 +1111,8 @@ func promptWorkspaceSelectionFromSummariesWithReader(workspaces []workspaceSumma
11111111
fmt.Println()
11121112
fmt.Println("Select workspace")
11131113
fmt.Println()
1114-
printPlainTable([]string{"#", "Workspace", "Workspace ID", "Database", "Updated", "Mounted"}, checkpointWorkspacePromptRows(workspaces, workspaceListMounts(workspaces)))
1114+
headers := []string{"#", "Workspace", "Workspace ID", "Database", "Updated", "Mounted"}
1115+
printPlainTable(headers, checkpointWorkspacePromptRows(workspaces, workspaceListMounts(workspaces)))
11151116
fmt.Println()
11161117
fmt.Print("Workspace: ")
11171118

cmd/afs/afs_commands_test.go

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,23 @@ import (
1212

1313
"github.com/alicebob/miniredis/v2"
1414
"github.com/redis/agent-filesystem/internal/controlplane"
15+
"github.com/redis/agent-filesystem/internal/mcptools"
1516
mountclient "github.com/redis/agent-filesystem/mount/client"
1617
"github.com/redis/go-redis/v9"
1718
)
1819

1920
type stubAFSControlPlane struct {
20-
workspaces controlplane.WorkspaceListResponse
21+
workspaces controlplane.WorkspaceListResponse
22+
workspacesErr error
23+
workspaceConfig controlplane.WorkspaceConfig
24+
workspaceConfigOK bool
25+
workspaceConfigErr error
2126
}
2227

2328
func (s stubAFSControlPlane) ListWorkspaceSummaries(context.Context) (controlplane.WorkspaceListResponse, error) {
29+
if s.workspacesErr != nil {
30+
return controlplane.WorkspaceListResponse{}, s.workspacesErr
31+
}
2432
return s.workspaces, nil
2533
}
2634

@@ -29,6 +37,12 @@ func (s stubAFSControlPlane) GetWorkspace(context.Context, string) (controlplane
2937
}
3038

3139
func (s stubAFSControlPlane) GetWorkspaceConfig(context.Context, string) (controlplane.WorkspaceConfig, error) {
40+
if s.workspaceConfigErr != nil {
41+
return controlplane.WorkspaceConfig{}, s.workspaceConfigErr
42+
}
43+
if s.workspaceConfigOK {
44+
return s.workspaceConfig, nil
45+
}
3246
return controlplane.WorkspaceConfig{}, fmt.Errorf("unexpected GetWorkspaceConfig call")
3347
}
3448

@@ -112,6 +126,18 @@ func (s stubAFSControlPlane) GetFileContent(context.Context, string, string, str
112126
return controlplane.FileContentResponse{}, fmt.Errorf("unexpected GetFileContent call")
113127
}
114128

129+
func (s stubAFSControlPlane) QueryWorkspace(context.Context, string, mcptools.FileQueryRequest) (mcptools.FileQueryResponse, error) {
130+
return mcptools.FileQueryResponse{}, fmt.Errorf("unexpected QueryWorkspace call")
131+
}
132+
133+
func (s stubAFSControlPlane) QueryIndexStatus(context.Context, string, controlplane.WorkspaceQueryIndexStatusRequest) (controlplane.WorkspaceQueryIndexStatus, error) {
134+
return controlplane.WorkspaceQueryIndexStatus{}, fmt.Errorf("unexpected QueryIndexStatus call")
135+
}
136+
137+
func (s stubAFSControlPlane) RebuildQueryIndex(context.Context, string, controlplane.WorkspaceQueryIndexRebuildRequest) (controlplane.WorkspaceQueryIndexRebuildResponse, error) {
138+
return controlplane.WorkspaceQueryIndexRebuildResponse{}, fmt.Errorf("unexpected RebuildQueryIndex call")
139+
}
140+
115141
func (s stubAFSControlPlane) DiffWorkspace(context.Context, string, string, string) (controlplane.WorkspaceDiffResponse, error) {
116142
return controlplane.WorkspaceDiffResponse{}, fmt.Errorf("unexpected DiffWorkspace call")
117143
}
@@ -495,6 +521,90 @@ func TestResolveWorkspaceSelectionPrefersCWDMountedWorkspace(t *testing.T) {
495521
}
496522
}
497523

524+
func TestResolveWorkspaceSetDefaultSelectionUsesCurrentConfigMountWhenCatalogMissesWorkspace(t *testing.T) {
525+
t.Helper()
526+
527+
homeDir := withTempHome(t)
528+
cfg := defaultConfig()
529+
cfg.ProductMode = productModeCloud
530+
cfg.URL = "https://afs.cloud"
531+
cfg.DatabaseID = "afs-cloud"
532+
533+
if err := saveMountRegistry(mountRegistry{Mounts: []mountRecord{{
534+
ID: "mnt_first",
535+
Workspace: "first-workspace",
536+
WorkspaceID: "ws_first",
537+
LocalPath: filepath.Join(homeDir, "first-workspace"),
538+
ProductMode: productModeCloud,
539+
ControlPlaneURL: cfg.URL,
540+
ControlPlaneDatabase: cfg.DatabaseID,
541+
PID: os.Getpid(),
542+
Mode: modeSync,
543+
StartedAt: time.Now().UTC(),
544+
}}}); err != nil {
545+
t.Fatalf("saveMountRegistry() returned error: %v", err)
546+
}
547+
548+
selection, err := resolveWorkspaceSetDefaultSelection(context.Background(), cfg, stubAFSControlPlane{
549+
workspaces: controlplane.WorkspaceListResponse{
550+
Items: []controlplane.WorkspaceSummary{
551+
{ID: "ws_new", Name: "new", DatabaseID: cfg.DatabaseID},
552+
},
553+
},
554+
}, "first-workspace")
555+
if err != nil {
556+
t.Fatalf("resolveWorkspaceSetDefaultSelection() returned error: %v", err)
557+
}
558+
if selection.ID != "ws_first" || selection.Name != "first-workspace" || selection.Source != workspaceSelectionExplicit {
559+
t.Fatalf("selection = %+v, want mounted first-workspace", selection)
560+
}
561+
}
562+
563+
func TestResolveWorkspaceSetDefaultSelectionExplainsMountedWorkspaceFromOtherConfig(t *testing.T) {
564+
t.Helper()
565+
566+
homeDir := withTempHome(t)
567+
cfg := defaultConfig()
568+
cfg.ProductMode = productModeCloud
569+
cfg.URL = "https://afs.cloud"
570+
cfg.DatabaseID = "afs-cloud"
571+
572+
if err := saveMountRegistry(mountRegistry{Mounts: []mountRecord{{
573+
ID: "mnt_first",
574+
Workspace: "first-workspace",
575+
WorkspaceID: "ws_first",
576+
LocalPath: filepath.Join(homeDir, "first-workspace"),
577+
ProductMode: productModeSelfHosted,
578+
ControlPlaneURL: "http://127.0.0.1:8091",
579+
ControlPlaneDatabase: "localhost-6379",
580+
PID: os.Getpid(),
581+
Mode: modeSync,
582+
StartedAt: time.Now().UTC(),
583+
}}}); err != nil {
584+
t.Fatalf("saveMountRegistry() returned error: %v", err)
585+
}
586+
587+
_, err := resolveWorkspaceSetDefaultSelection(context.Background(), cfg, stubAFSControlPlane{
588+
workspaces: controlplane.WorkspaceListResponse{
589+
Items: []controlplane.WorkspaceSummary{
590+
{ID: "ws_new", Name: "new", DatabaseID: cfg.DatabaseID},
591+
},
592+
},
593+
}, "first-workspace")
594+
if err == nil {
595+
t.Fatal("resolveWorkspaceSetDefaultSelection() returned nil error, want config mismatch")
596+
}
597+
for _, want := range []string{
598+
`workspace "first-workspace" is mounted from Self-managed http://127.0.0.1:8091 (localhost-6379)`,
599+
"current config uses Cloud-managed https://afs.cloud (afs-cloud)",
600+
"status --verbose",
601+
} {
602+
if !strings.Contains(err.Error(), want) {
603+
t.Fatalf("error = %q, want %q", err, want)
604+
}
605+
}
606+
}
607+
498608
func TestResolveWorkspaceSelectionUsesOnlyMountedWorkspace(t *testing.T) {
499609
t.Helper()
500610

cmd/afs/afs_mcp.go

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,6 @@ func (s *afsMCPServer) Tools(_ context.Context) []mcpproto.Tool {
405405
"path": map[string]string{"type": "string", "description": "Absolute workspace directory path to search within, for example / or /src", "default": "/"},
406406
"pattern": map[string]string{"type": "string", "description": "Basename glob pattern, for example *.go or [Mm]akefile"},
407407
"kind": map[string]string{"type": "string", "description": "Optional kind filter: file, dir, symlink, or any", "default": "file"},
408-
"limit": map[string]string{"type": "integer", "description": "Maximum number of results to return", "default": "100"},
409408
},
410409
"required": []string{"pattern"},
411410
},
@@ -536,6 +535,39 @@ func (s *afsMCPServer) Tools(_ context.Context) []mcpproto.Tool {
536535
"required": []string{"pattern"},
537536
},
538537
},
538+
{
539+
Name: "file_query",
540+
Description: "Rank workspace files for a concept or natural-language question. Use this when exact text is unknown or when QMD-style lex/vec/hyde clauses are useful. Use file_grep instead for deterministic exact line matches.",
541+
InputSchema: map[string]any{
542+
"type": "object",
543+
"properties": map[string]any{
544+
"workspace": map[string]string{"type": "string", "description": "Workspace name"},
545+
"path": map[string]string{"type": "string", "description": "Absolute workspace path to search within, for example / or /src", "default": "/"},
546+
"query": map[string]string{"type": "string", "description": "Plain query text or a QMD-style typed query document using lex:, vec:, hyde:, and intent:"},
547+
"mode": map[string]string{"type": "string", "description": "query (default), keyword, or semantic", "default": "query"},
548+
"searches": map[string]any{
549+
"type": "array",
550+
"description": "Optional typed searches for mode=query",
551+
"items": map[string]any{
552+
"type": "object",
553+
"properties": map[string]any{
554+
"type": map[string]string{"type": "string", "description": "lex, vec, or hyde"},
555+
"query": map[string]string{"type": "string", "description": "Query text for this clause"},
556+
},
557+
"required": []string{"type", "query"},
558+
},
559+
},
560+
"intent": map[string]string{"type": "string", "description": "Extra retrieval intent"},
561+
"limit": map[string]string{"type": "integer", "description": "Maximum results", "default": "10"},
562+
"all": map[string]string{"type": "boolean", "description": "Return all results"},
563+
"min_score": map[string]string{"type": "number", "description": "Minimum score"},
564+
"candidate_limit": map[string]string{"type": "integer", "description": "Candidate result limit"},
565+
"rerank": map[string]string{"type": "string", "description": "auto or none", "default": "auto"},
566+
"explain": map[string]string{"type": "boolean", "description": "Include retrieval explanation"},
567+
"chunk_strategy": map[string]string{"type": "string", "description": "auto or regex"},
568+
},
569+
},
570+
},
539571
}
540572
filtered := make([]mcpproto.Tool, 0, len(tools))
541573
for _, tool := range tools {
@@ -612,6 +644,8 @@ func (s *afsMCPServer) CallTool(ctx context.Context, name string, args map[strin
612644
value, err = s.toolFilePatch(ctx, args)
613645
case "file_grep":
614646
value, err = s.toolFileGrep(ctx, args)
647+
case "file_query":
648+
value, err = s.toolFileQuery(ctx, args)
615649
default:
616650
err = fmt.Errorf("unknown tool %q", name)
617651
}
@@ -1221,14 +1255,6 @@ func (s *afsMCPServer) toolFileGlob(ctx context.Context, args map[string]any) (a
12211255
default:
12221256
return nil, fmt.Errorf("argument %q must be one of file, dir, symlink, or any", "kind")
12231257
}
1224-
limit, err := mcpInt(args, "limit", 100)
1225-
if err != nil {
1226-
return nil, err
1227-
}
1228-
if limit <= 0 {
1229-
limit = 100
1230-
}
1231-
12321258
fsKey, _, _, err := s.store.ensureWorkspaceRoot(ctx, workspace)
12331259
if err != nil {
12341260
return nil, err
@@ -1252,11 +1278,6 @@ func (s *afsMCPServer) toolFileGlob(ctx context.Context, args map[string]any) (a
12521278
if err != nil {
12531279
return nil, err
12541280
}
1255-
truncated := false
1256-
if len(matches) > limit {
1257-
truncated = true
1258-
matches = matches[:limit]
1259-
}
12601281
items := make([]mcpFileListItem, 0, len(matches))
12611282
for _, matchPath := range matches {
12621283
item, err := workspaceFileListItem(ctx, fsClient, matchPath)
@@ -1282,7 +1303,6 @@ func (s *afsMCPServer) toolFileGlob(ctx context.Context, args map[string]any) (a
12821303
"pattern": pattern,
12831304
"kind": ternaryString(kind == "", "any", kind),
12841305
"count": len(items),
1285-
"truncated": truncated,
12861306
"items": items,
12871307
}, nil
12881308
}
@@ -1695,6 +1715,21 @@ func (s *afsMCPServer) toolFileGrep(ctx context.Context, args map[string]any) (a
16951715
return result, nil
16961716
}
16971717

1718+
func (s *afsMCPServer) toolFileQuery(ctx context.Context, args map[string]any) (any, error) {
1719+
workspace, err := s.resolveWorkspaceArg(ctx, args, "workspace")
1720+
if err != nil {
1721+
return nil, err
1722+
}
1723+
request, err := mcptools.FileQueryRequestFromArgs(args, workspace)
1724+
if err != nil {
1725+
return nil, err
1726+
}
1727+
request.Workspace = workspace
1728+
request.Path = normalizeAFSGrepPath(request.Path)
1729+
1730+
return s.service.QueryWorkspace(ctx, workspace, request)
1731+
}
1732+
16981733
func (s *afsMCPServer) grepLocalWorkspace(ctx context.Context, workspace, searchPath string, opts grepOptions) (any, error) {
16991734
workspaceMeta, err := s.store.getWorkspaceMeta(ctx, workspace)
17001735
if err != nil {

0 commit comments

Comments
 (0)