Skip to content

Commit ebe5da6

Browse files
Copilotalexec
andauthored
Add selector flags for filtering memory files by frontmatter metadata (#8)
* Initial plan * Add selector flag (-s) for filtering memories based on frontmatter Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Refactor integration tests to use strings.Contains from standard library Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Update README with selector flag documentation and examples Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Refactor selectors to use -s (include) and -S (exclude) flags instead of != syntax Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Refactor selectorMap to reuse paramMap with custom Set method for trimming Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
1 parent 17dc5bd commit ebe5da6

5 files changed

Lines changed: 556 additions & 3 deletions

File tree

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,27 @@ Options:
2929
Default: .coding-agent-context, ~/.config/coding-agent-context, /var/local/coding-agent-context
3030
-o <directory> Output directory for generated files (default: .)
3131
-p <key=value> Template parameter for prompt substitution (can be used multiple times)
32+
-s <key=value> Include memories with matching frontmatter (can be used multiple times)
33+
-S <key=value> Exclude memories with matching frontmatter (can be used multiple times)
3234
```
3335

3436
**Example:**
3537
```bash
3638
coding-agent-context -p feature="Authentication" -p language=Go add-feature
3739
```
3840

41+
**Example with selectors:**
42+
```bash
43+
# Include only production memories
44+
coding-agent-context -s env=production deploy
45+
46+
# Exclude test memories
47+
coding-agent-context -S env=test deploy
48+
49+
# Combine include and exclude selectors
50+
coding-agent-context -s env=production -S language=python deploy
51+
```
52+
3953
## Quick Start
4054

4155
1. Create a context directory structure:
@@ -114,6 +128,8 @@ Markdown files included in every generated context. Bootstrap scripts can be pro
114128
**Example** (`.coding-agent-context/memories/setup.md`):
115129
```markdown
116130
---
131+
env: development
132+
language: go
117133
---
118134
# Development Setup
119135

@@ -129,6 +145,56 @@ npm install
129145
For each memory file `<name>.md`, you can optionally create a corresponding `<name>-bootstrap` file that will be executed during setup.
130146

131147

148+
## Filtering Memories with Selectors
149+
150+
Use the `-s` and `-S` flags to filter which memory files are included based on their frontmatter metadata.
151+
152+
### Selector Syntax
153+
154+
- **`-s key=value`** - Include memories where the frontmatter key matches the value
155+
- **`-S key=value`** - Exclude memories where the frontmatter key matches the value
156+
- If a key doesn't exist in a memory's frontmatter, the memory is allowed (not filtered out)
157+
- Multiple selectors of the same type use AND logic (all must match)
158+
159+
### Examples
160+
161+
**Include only production memories:**
162+
```bash
163+
coding-agent-context -s env=production deploy
164+
```
165+
166+
**Exclude test environment:**
167+
```bash
168+
coding-agent-context -S env=test deploy
169+
```
170+
171+
**Combine include and exclude:**
172+
```bash
173+
# Include production but exclude python
174+
coding-agent-context -s env=production -S language=python deploy
175+
```
176+
177+
**Multiple includes:**
178+
```bash
179+
# Only production Go backend memories
180+
coding-agent-context -s env=production -s language=go -s tier=backend deploy
181+
```
182+
183+
### How It Works
184+
185+
When you run with selectors, the tool logs which files are included or excluded:
186+
187+
```
188+
INFO Including memory file path=.coding-agent-context/memories/production.md
189+
INFO Excluding memory file (does not match include selectors) path=.coding-agent-context/memories/development.md
190+
INFO Including memory file path=.coding-agent-context/memories/nofrontmatter.md
191+
```
192+
193+
**Important:** Files without the specified frontmatter keys are still included. This allows you to have generic memories that apply to all scenarios.
194+
195+
If no selectors are specified, all memory files are included.
196+
197+
132198
## Output Files
133199

134200
- **`prompt.md`** - Combined output with all memories and the task prompt

integration_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"os"
55
"os/exec"
66
"path/filepath"
7+
"strings"
78
"testing"
89
)
910

@@ -234,3 +235,185 @@ func TestMultipleBootstrapFiles(t *testing.T) {
234235
t.Errorf("expected 2 bootstrap files, got %d", len(files))
235236
}
236237
}
238+
239+
func TestSelectorFiltering(t *testing.T) {
240+
// Build the binary
241+
binaryPath := filepath.Join(t.TempDir(), "coding-agent-context")
242+
cmd := exec.Command("go", "build", "-o", binaryPath, ".")
243+
if output, err := cmd.CombinedOutput(); err != nil {
244+
t.Fatalf("failed to build binary: %v\n%s", err, output)
245+
}
246+
247+
// Create a temporary directory structure
248+
tmpDir := t.TempDir()
249+
contextDir := filepath.Join(tmpDir, ".coding-agent-context")
250+
memoriesDir := filepath.Join(contextDir, "memories")
251+
promptsDir := filepath.Join(contextDir, "prompts")
252+
outputDir := filepath.Join(tmpDir, "output")
253+
254+
if err := os.MkdirAll(memoriesDir, 0755); err != nil {
255+
t.Fatalf("failed to create memories dir: %v", err)
256+
}
257+
if err := os.MkdirAll(promptsDir, 0755); err != nil {
258+
t.Fatalf("failed to create prompts dir: %v", err)
259+
}
260+
261+
// Create memory files with different frontmatter
262+
if err := os.WriteFile(filepath.Join(memoriesDir, "prod.md"), []byte("---\nenv: production\nlanguage: go\n---\n# Production\nProd content\n"), 0644); err != nil {
263+
t.Fatalf("failed to write memory file: %v", err)
264+
}
265+
if err := os.WriteFile(filepath.Join(memoriesDir, "dev.md"), []byte("---\nenv: development\nlanguage: python\n---\n# Development\nDev content\n"), 0644); err != nil {
266+
t.Fatalf("failed to write memory file: %v", err)
267+
}
268+
if err := os.WriteFile(filepath.Join(memoriesDir, "test.md"), []byte("---\nenv: test\nlanguage: go\n---\n# Test\nTest content\n"), 0644); err != nil {
269+
t.Fatalf("failed to write memory file: %v", err)
270+
}
271+
// Create a file without frontmatter (should be included by default)
272+
if err := os.WriteFile(filepath.Join(memoriesDir, "nofm.md"), []byte("---\n---\n# No Frontmatter\nNo FM content\n"), 0644); err != nil {
273+
t.Fatalf("failed to write memory file: %v", err)
274+
}
275+
276+
// Create a prompt file
277+
if err := os.WriteFile(filepath.Join(promptsDir, "test-task.md"), []byte("---\n---\n# Test Task\n"), 0644); err != nil {
278+
t.Fatalf("failed to write prompt file: %v", err)
279+
}
280+
281+
// Test 1: Include by env=production
282+
cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-s", "env=production", "test-task")
283+
cmd.Dir = tmpDir
284+
if output, err := cmd.CombinedOutput(); err != nil {
285+
t.Fatalf("failed to run binary: %v\n%s", err, output)
286+
}
287+
288+
promptOutput := filepath.Join(outputDir, "prompt.md")
289+
content, err := os.ReadFile(promptOutput)
290+
if err != nil {
291+
t.Fatalf("failed to read prompt output: %v", err)
292+
}
293+
contentStr := string(content)
294+
if !strings.Contains(contentStr, "Prod content") {
295+
t.Errorf("Expected production content in output")
296+
}
297+
if strings.Contains(contentStr, "Dev content") {
298+
t.Errorf("Did not expect development content in output")
299+
}
300+
if strings.Contains(contentStr, "Test content") {
301+
t.Errorf("Did not expect test content in output")
302+
}
303+
// File without env key should be included (key missing is allowed)
304+
if !strings.Contains(contentStr, "No FM content") {
305+
t.Errorf("Expected no frontmatter content in output (missing key should be allowed)")
306+
}
307+
308+
// Clean output for next test
309+
os.RemoveAll(outputDir)
310+
311+
// Test 2: Include by language=go (should include prod and test, and nofm)
312+
cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-s", "language=go", "test-task")
313+
cmd.Dir = tmpDir
314+
if output, err := cmd.CombinedOutput(); err != nil {
315+
t.Fatalf("failed to run binary: %v\n%s", err, output)
316+
}
317+
318+
content, err = os.ReadFile(promptOutput)
319+
if err != nil {
320+
t.Fatalf("failed to read prompt output: %v", err)
321+
}
322+
contentStr = string(content)
323+
if !strings.Contains(contentStr, "Prod content") {
324+
t.Errorf("Expected production content in output")
325+
}
326+
if strings.Contains(contentStr, "Dev content") {
327+
t.Errorf("Did not expect development content in output")
328+
}
329+
if !strings.Contains(contentStr, "Test content") {
330+
t.Errorf("Expected test content in output")
331+
}
332+
if !strings.Contains(contentStr, "No FM content") {
333+
t.Errorf("Expected no frontmatter content in output (missing key should be allowed)")
334+
}
335+
336+
// Clean output for next test
337+
os.RemoveAll(outputDir)
338+
339+
// Test 3: Exclude by env=production (should include dev and test, and nofm)
340+
cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-S", "env=production", "test-task")
341+
cmd.Dir = tmpDir
342+
if output, err := cmd.CombinedOutput(); err != nil {
343+
t.Fatalf("failed to run binary: %v\n%s", err, output)
344+
}
345+
346+
content, err = os.ReadFile(promptOutput)
347+
if err != nil {
348+
t.Fatalf("failed to read prompt output: %v", err)
349+
}
350+
contentStr = string(content)
351+
if strings.Contains(contentStr, "Prod content") {
352+
t.Errorf("Did not expect production content in output")
353+
}
354+
if !strings.Contains(contentStr, "Dev content") {
355+
t.Errorf("Expected development content in output")
356+
}
357+
if !strings.Contains(contentStr, "Test content") {
358+
t.Errorf("Expected test content in output")
359+
}
360+
if !strings.Contains(contentStr, "No FM content") {
361+
t.Errorf("Expected no frontmatter content in output (missing key should be allowed)")
362+
}
363+
364+
// Clean output for next test
365+
os.RemoveAll(outputDir)
366+
367+
// Test 4: Multiple includes env=production language=go (should include only prod and nofm)
368+
cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-s", "env=production", "-s", "language=go", "test-task")
369+
cmd.Dir = tmpDir
370+
if output, err := cmd.CombinedOutput(); err != nil {
371+
t.Fatalf("failed to run binary: %v\n%s", err, output)
372+
}
373+
374+
content, err = os.ReadFile(promptOutput)
375+
if err != nil {
376+
t.Fatalf("failed to read prompt output: %v", err)
377+
}
378+
contentStr = string(content)
379+
if !strings.Contains(contentStr, "Prod content") {
380+
t.Errorf("Expected production content in output")
381+
}
382+
if strings.Contains(contentStr, "Dev content") {
383+
t.Errorf("Did not expect development content in output")
384+
}
385+
if strings.Contains(contentStr, "Test content") {
386+
t.Errorf("Did not expect test content in output")
387+
}
388+
if !strings.Contains(contentStr, "No FM content") {
389+
t.Errorf("Expected no frontmatter content in output (missing key should be allowed)")
390+
}
391+
392+
// Clean output for next test
393+
os.RemoveAll(outputDir)
394+
395+
// Test 5: Mix of include and exclude -s env=production -S language=python (should include only prod with go)
396+
cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-s", "env=production", "-S", "language=python", "test-task")
397+
cmd.Dir = tmpDir
398+
if output, err := cmd.CombinedOutput(); err != nil {
399+
t.Fatalf("failed to run binary: %v\n%s", err, output)
400+
}
401+
402+
content, err = os.ReadFile(promptOutput)
403+
if err != nil {
404+
t.Fatalf("failed to read prompt output: %v", err)
405+
}
406+
contentStr = string(content)
407+
if !strings.Contains(contentStr, "Prod content") {
408+
t.Errorf("Expected production content in output")
409+
}
410+
if strings.Contains(contentStr, "Dev content") {
411+
t.Errorf("Did not expect development content in output")
412+
}
413+
if strings.Contains(contentStr, "Test content") {
414+
t.Errorf("Did not expect test content in output")
415+
}
416+
if !strings.Contains(contentStr, "No FM content") {
417+
t.Errorf("Expected no frontmatter content in output (missing keys should be allowed)")
418+
}
419+
}

main.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ var (
1919
dirs stringSlice
2020
outputDir = "."
2121
params = make(paramMap)
22+
includes = make(selectorMap)
23+
excludes = make(selectorMap)
2224
)
2325

2426
func main() {
@@ -37,6 +39,8 @@ func main() {
3739
flag.Var(&dirs, "d", "Directory to include in the context. Can be specified multiple times.")
3840
flag.StringVar(&outputDir, "o", ".", "Directory to write the context files to.")
3941
flag.Var(&params, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.")
42+
flag.Var(&includes, "s", "Include memories with matching frontmatter. Can be specified multiple times as key=value.")
43+
flag.Var(&excludes, "S", "Exclude memories with matching frontmatter. Can be specified multiple times as key=value.")
4044

4145
flag.Usage = func() {
4246
w := flag.CommandLine.Output()
@@ -97,13 +101,25 @@ func run(args []string) error {
97101
return nil
98102
}
99103

100-
slog.Info("Including memory file", "path", path)
101-
102-
content, err := parseMarkdownFile(path, &struct{}{})
104+
// Parse frontmatter to check selectors
105+
var frontmatter map[string]string
106+
content, err := parseMarkdownFile(path, &frontmatter)
103107
if err != nil {
104108
return fmt.Errorf("failed to parse markdown file: %w", err)
105109
}
106110

111+
// Check if file matches include and exclude selectors
112+
if !includes.matchesIncludes(frontmatter) {
113+
slog.Info("Excluding memory file (does not match include selectors)", "path", path)
114+
return nil
115+
}
116+
if !excludes.matchesExcludes(frontmatter) {
117+
slog.Info("Excluding memory file (matches exclude selectors)", "path", path)
118+
return nil
119+
}
120+
121+
slog.Info("Including memory file", "path", path)
122+
107123
// Check for a bootstrap file named <markdown-file-without-md-suffix>-bootstrap
108124
// For example, setup.md -> setup-bootstrap
109125
baseNameWithoutExt := strings.TrimSuffix(path, ".md")

selector_map.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// selectorMap reuses paramMap for parsing key=value pairs
9+
type selectorMap paramMap
10+
11+
func (s *selectorMap) String() string {
12+
return (*paramMap)(s).String()
13+
}
14+
15+
func (s *selectorMap) Set(value string) error {
16+
// Parse key=value format with trimming
17+
kv := strings.SplitN(value, "=", 2)
18+
if len(kv) != 2 {
19+
return fmt.Errorf("invalid selector format: %s", value)
20+
}
21+
if *s == nil {
22+
*s = make(selectorMap)
23+
}
24+
// Trim spaces from both key and value for selectors
25+
(*s)[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
26+
return nil
27+
}
28+
29+
// matchesIncludes returns true if the frontmatter matches all include selectors
30+
// If a key doesn't exist in frontmatter, it's allowed
31+
func (includes *selectorMap) matchesIncludes(frontmatter map[string]string) bool {
32+
for key, value := range *includes {
33+
fmValue, exists := frontmatter[key]
34+
// If key exists, it must match the value
35+
if exists && fmValue != value {
36+
return false
37+
}
38+
// If key doesn't exist, allow it
39+
}
40+
return true
41+
}
42+
43+
// matchesExcludes returns true if the frontmatter doesn't match any exclude selectors
44+
// If a key doesn't exist in frontmatter, it's allowed
45+
func (excludes *selectorMap) matchesExcludes(frontmatter map[string]string) bool {
46+
for key, value := range *excludes {
47+
fmValue, exists := frontmatter[key]
48+
// If key exists and matches the value, exclude it
49+
if exists && fmValue == value {
50+
return false
51+
}
52+
// If key doesn't exist, allow it
53+
}
54+
return true
55+
}

0 commit comments

Comments
 (0)