summaryrefslogtreecommitdiff
path: root/internal/promptstore
diff options
context:
space:
mode:
Diffstat (limited to 'internal/promptstore')
-rw-r--r--internal/promptstore/backup_test.go308
-rw-r--r--internal/promptstore/builtin.go156
-rw-r--r--internal/promptstore/store.go547
-rw-r--r--internal/promptstore/store_test.go311
-rw-r--r--internal/promptstore/types.go39
5 files changed, 1361 insertions, 0 deletions
diff --git a/internal/promptstore/backup_test.go b/internal/promptstore/backup_test.go
new file mode 100644
index 0000000..903f021
--- /dev/null
+++ b/internal/promptstore/backup_test.go
@@ -0,0 +1,308 @@
+// Summary: Tests for automatic backup functionality
+package promptstore
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestAutomaticBackupOnCreate(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Verify backups directory exists
+ backupDir := filepath.Join(tmpDir, "backups")
+ if _, err := os.Stat(backupDir); os.IsNotExist(err) {
+ t.Fatal("Backups directory was not created")
+ }
+
+ // Add initial prompt to user.jsonl
+ initial := &Prompt{
+ Name: "initial",
+ Title: "Initial Prompt",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Initial"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ if err := store.Create(initial); err != nil {
+ t.Fatalf("Create() initial error = %v", err)
+ }
+
+ // Count backups after first create
+ backups1, err := countBackups(backupDir)
+ if err != nil {
+ t.Fatalf("countBackups() error = %v", err)
+ }
+
+ // Create another prompt - should create backup automatically
+ second := &Prompt{
+ Name: "second",
+ Title: "Second Prompt",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Second"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ if err := store.Create(second); err != nil {
+ t.Fatalf("Create() second error = %v", err)
+ }
+
+ // Count backups after second create
+ backups2, err := countBackups(backupDir)
+ if err != nil {
+ t.Fatalf("countBackups() error = %v", err)
+ }
+
+ // Should have more backups now
+ if backups2 <= backups1 {
+ t.Errorf("Expected more backups after Create(), got %d, had %d", backups2, backups1)
+ }
+
+ t.Logf("✓ Automatic backup working: %d backups after first create, %d after second", backups1, backups2)
+}
+
+func TestAutomaticBackupOnUpdate(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Create initial prompt
+ prompt := &Prompt{
+ Name: "test_update_backup",
+ Title: "Original",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Original"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ if err := store.Create(prompt); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ backupDir := filepath.Join(tmpDir, "backups")
+ backupsAfterCreate, _ := countBackups(backupDir)
+
+ // Update prompt - should create backup
+ prompt.Title = "Updated"
+ if err := store.Update(prompt); err != nil {
+ t.Fatalf("Update() error = %v", err)
+ }
+
+ backupsAfterUpdate, _ := countBackups(backupDir)
+
+ if backupsAfterUpdate <= backupsAfterCreate {
+ t.Errorf("Expected backup after Update(), got %d, had %d", backupsAfterUpdate, backupsAfterCreate)
+ }
+
+ t.Logf("✓ Automatic backup on update: %d backups after create, %d after update", backupsAfterCreate, backupsAfterUpdate)
+}
+
+func TestAutomaticBackupOnDelete(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Create prompt
+ prompt := &Prompt{
+ Name: "test_delete_backup",
+ Title: "To Delete",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Delete me"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ if err := store.Create(prompt); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ backupDir := filepath.Join(tmpDir, "backups")
+ backupsAfterCreate, _ := countBackups(backupDir)
+
+ // Delete prompt - should create backup
+ if err := store.Delete("test_delete_backup"); err != nil {
+ t.Fatalf("Delete() error = %v", err)
+ }
+
+ backupsAfterDelete, _ := countBackups(backupDir)
+
+ if backupsAfterDelete <= backupsAfterCreate {
+ t.Errorf("Expected backup after Delete(), got %d, had %d", backupsAfterDelete, backupsAfterCreate)
+ }
+
+ t.Logf("✓ Automatic backup on delete: %d backups after create, %d after delete", backupsAfterCreate, backupsAfterDelete)
+}
+
+func countBackups(backupDir string) (int, error) {
+ entries, err := os.ReadDir(backupDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return 0, nil
+ }
+ return 0, err
+ }
+
+ count := 0
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ count++
+ }
+ }
+ return count, nil
+}
+
+func TestListBackups(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Create prompts to trigger backups
+ for i := 0; i < 3; i++ {
+ prompt := &Prompt{
+ Name: fmt.Sprintf("test%d", i),
+ Title: fmt.Sprintf("Test %d", i),
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Test"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ if err := store.Create(prompt); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+ time.Sleep(10 * time.Millisecond) // Ensure different timestamps
+ }
+
+ // List backups (returns []string filenames)
+ backups, err := store.(*JSONLStore).ListBackups()
+ if err != nil {
+ t.Fatalf("ListBackups() error = %v", err)
+ }
+
+ // Log number of backups found
+ t.Logf("Found %d backups", len(backups))
+
+ // Verify backup filenames if any exist
+ if len(backups) > 0 && backups[0] == "" {
+ t.Error("Backup filename is empty")
+ }
+}
+
+func TestRestoreBackup(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Create initial prompt
+ initial := &Prompt{
+ Name: "test_restore",
+ Title: "Original Title",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Original"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ if err := store.Create(initial); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ // Create a second prompt to trigger backup of the first
+ second := &Prompt{
+ Name: "second",
+ Title: "Second",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Second"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ if err := store.Create(second); err != nil {
+ t.Fatalf("Create() second error = %v", err)
+ }
+
+ // Now there should be backups
+ backups, err := store.(*JSONLStore).ListBackups()
+ if err != nil {
+ t.Fatalf("ListBackups() error = %v", err)
+ }
+ if len(backups) == 0 {
+ t.Skip("No backups available - backup mechanism may not create backups immediately")
+ }
+
+ // Modify the prompt
+ initial.Title = "Modified Title"
+ if err := store.Update(initial); err != nil {
+ t.Fatalf("Update() error = %v", err)
+ }
+
+ // Verify modification
+ modified, err := store.Get("test_restore")
+ if err != nil {
+ t.Fatalf("Get() error = %v", err)
+ }
+ if modified.Title != "Modified Title" {
+ t.Fatalf("Expected modified title, got %v", modified.Title)
+ }
+
+ // Get updated list of backups
+ backups, err = store.(*JSONLStore).ListBackups()
+ if err != nil {
+ t.Fatalf("ListBackups() error = %v", err)
+ }
+ if len(backups) == 0 {
+ t.Skip("No backups available after update")
+ }
+
+ // Restore from backup (use the most recent backup)
+ if err := store.(*JSONLStore).RestoreBackup(backups[0]); err != nil {
+ t.Fatalf("RestoreBackup() error = %v", err)
+ }
+
+ // Reload and verify restoration
+ store2, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ restored, err := store2.Get("test_restore")
+ if err == nil {
+ t.Logf("Restored prompt title: %v", restored.Title)
+ }
+}
+
+func TestRestoreBackup_NotFound(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Try to restore non-existent backup
+ err = store.(*JSONLStore).RestoreBackup("nonexistent.jsonl")
+ if err == nil {
+ t.Fatal("Expected error for non-existent backup")
+ }
+}
+
+func TestListBackups_EmptyDirectory(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // List backups when none exist (before any creates)
+ backups, err := store.(*JSONLStore).ListBackups()
+ if err != nil {
+ t.Fatalf("ListBackups() error = %v", err)
+ }
+
+ // Should return empty list, not error
+ // Note: NewJSONLStore might create initial backups, so we just verify no error
+ t.Logf("Found %d backups in empty directory", len(backups))
+}
diff --git a/internal/promptstore/builtin.go b/internal/promptstore/builtin.go
new file mode 100644
index 0000000..ac4c830
--- /dev/null
+++ b/internal/promptstore/builtin.go
@@ -0,0 +1,156 @@
+// Summary: Built-in prompts for common development tasks.
+package promptstore
+
+import "time"
+
+// GetBuiltinPrompts returns the default set of prompts.
+// These are written to default.jsonl on first run.
+func GetBuiltinPrompts() []Prompt {
+ now := time.Now()
+
+ return []Prompt{
+ {
+ Name: "code_review",
+ Title: "Request Code Review",
+ Description: "Analyzes code quality, style, and suggests improvements",
+ Arguments: []PromptArgument{
+ {Name: "code", Description: "The code to review", Required: true},
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: "Please review the following code for quality, style, and potential issues:\n\n{{code}}",
+ },
+ },
+ },
+ Tags: []string{"development", "review", "quality"},
+ Created: now,
+ Updated: now,
+ },
+ {
+ Name: "explain_code",
+ Title: "Explain Code",
+ Description: "Provides detailed explanation of what code does",
+ Arguments: []PromptArgument{
+ {Name: "code", Description: "The code to explain", Required: true},
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: "Please explain in detail what the following code does:\n\n{{code}}",
+ },
+ },
+ },
+ Tags: []string{"development", "documentation", "learning"},
+ Created: now,
+ Updated: now,
+ },
+ {
+ Name: "generate_tests",
+ Title: "Generate Unit Tests",
+ Description: "Generates unit tests for a function or class",
+ Arguments: []PromptArgument{
+ {Name: "code", Description: "The code to test", Required: true},
+ {Name: "language", Description: "Programming language", Required: false},
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: "Generate comprehensive unit tests for the following code:\n\nLanguage: {{language}}\n\n{{code}}",
+ },
+ },
+ },
+ Tags: []string{"development", "testing", "tdd"},
+ Created: now,
+ Updated: now,
+ },
+ {
+ Name: "document_function",
+ Title: "Generate Documentation",
+ Description: "Generates documentation comments and docstrings",
+ Arguments: []PromptArgument{
+ {Name: "code", Description: "The code to document", Required: true},
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: "Generate comprehensive documentation for the following code:\n\n{{code}}",
+ },
+ },
+ },
+ Tags: []string{"development", "documentation"},
+ Created: now,
+ Updated: now,
+ },
+ {
+ Name: "simplify_code",
+ Title: "Simplify Code",
+ Description: "Simplifies complex code while preserving behavior",
+ Arguments: []PromptArgument{
+ {Name: "code", Description: "The code to simplify", Required: true},
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: "Simplify the following code while preserving its behavior:\n\n{{code}}",
+ },
+ },
+ },
+ Tags: []string{"development", "refactoring", "quality"},
+ Created: now,
+ Updated: now,
+ },
+ {
+ Name: "fix_bugs",
+ Title: "Analyze and Fix Bugs",
+ Description: "Analyzes code for bugs and suggests fixes",
+ Arguments: []PromptArgument{
+ {Name: "code", Description: "The code to analyze", Required: true},
+ {Name: "error", Description: "Error message or symptoms", Required: false},
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: "Analyze the following code for bugs and suggest fixes:\n\nError: {{error}}\n\nCode:\n{{code}}",
+ },
+ },
+ },
+ Tags: []string{"development", "debugging", "bug-fix"},
+ Created: now,
+ Updated: now,
+ },
+ {
+ Name: "refactor_extract",
+ Title: "Extract Function",
+ Description: "Extracts code into a separate, reusable function",
+ Arguments: []PromptArgument{
+ {Name: "code", Description: "The code to extract", Required: true},
+ {Name: "function_name", Description: "Desired function name", Required: false},
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: "Extract the following code into a separate function named {{function_name}}:\n\n{{code}}",
+ },
+ },
+ },
+ Tags: []string{"development", "refactoring"},
+ Created: now,
+ Updated: now,
+ },
+ }
+}
diff --git a/internal/promptstore/store.go b/internal/promptstore/store.go
new file mode 100644
index 0000000..c1fcb9f
--- /dev/null
+++ b/internal/promptstore/store.go
@@ -0,0 +1,547 @@
+// Summary: Prompt storage interface and JSONL-based implementation.
+package promptstore
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// PromptStore defines the interface for prompt storage operations.
+// Allows easy mocking in tests.
+type PromptStore interface {
+ // List returns prompts with pagination support.
+ // cursor is the pagination token (empty for first page).
+ // limit is the max prompts to return per page.
+ List(cursor string, limit int) ([]Prompt, string, error)
+
+ // Get retrieves a prompt by name.
+ Get(name string) (*Prompt, error)
+
+ // Create adds a new prompt.
+ Create(prompt *Prompt) error
+
+ // Update modifies an existing prompt.
+ Update(prompt *Prompt) error
+
+ // Delete removes a prompt by name.
+ Delete(name string) error
+
+ // SearchByTags finds prompts matching all given tags (AND logic).
+ SearchByTags(tags []string) ([]Prompt, error)
+}
+
+// JSONLStore is a file-based prompt store using JSONL format.
+// Stores prompts in multiple JSONL files (default.jsonl for built-ins, user.jsonl for custom).
+// Automatically creates backups before any write operation.
+type JSONLStore struct {
+ dataDir string
+ mu sync.RWMutex
+
+ // File operation functions (can be mocked for testing)
+ readFileFn func(string) ([]byte, error)
+ writeFileFn func(string, []byte, os.FileMode) error
+
+ // Backup settings
+ maxBackups int // Maximum number of backups to keep (0 = unlimited)
+}
+
+// NewJSONLStore creates a new JSONL-based prompt store.
+// dataDir should be an absolute path (e.g., ~/.local/share/hexai/prompts/).
+func NewJSONLStore(dataDir string) (PromptStore, error) {
+ // Ensure directory exists
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ return nil, fmt.Errorf("cannot create prompts directory: %w", err)
+ }
+
+ // Create backups subdirectory
+ backupDir := filepath.Join(dataDir, "backups")
+ if err := os.MkdirAll(backupDir, 0o755); err != nil {
+ return nil, fmt.Errorf("cannot create backups directory: %w", err)
+ }
+
+ store := &JSONLStore{
+ dataDir: dataDir,
+ readFileFn: os.ReadFile,
+ writeFileFn: os.WriteFile,
+ maxBackups: 10, // Keep last 10 backups
+ }
+
+ // Initialize default.jsonl with built-in prompts if it doesn't exist
+ defaultPath := filepath.Join(dataDir, "default.jsonl")
+ if _, err := os.Stat(defaultPath); os.IsNotExist(err) {
+ if err := store.writeBuiltinPrompts(); err != nil {
+ return nil, fmt.Errorf("cannot write built-in prompts: %w", err)
+ }
+ }
+
+ return store, nil
+}
+
+// writeBuiltinPrompts writes the built-in prompts to default.jsonl.
+func (s *JSONLStore) writeBuiltinPrompts() error {
+ prompts := GetBuiltinPrompts()
+ defaultPath := filepath.Join(s.dataDir, "default.jsonl")
+
+ var lines []byte
+ for _, p := range prompts {
+ data, err := json.Marshal(p)
+ if err != nil {
+ return fmt.Errorf("marshal built-in prompt: %w", err)
+ }
+ lines = append(lines, data...)
+ lines = append(lines, '\n')
+ }
+
+ if err := s.writeFileFn(defaultPath, lines, 0o644); err != nil {
+ return fmt.Errorf("write default.jsonl: %w", err)
+ }
+
+ return nil
+}
+
+// List returns prompts with pagination.
+// cursor format: "<file>:<offset>" where file is "default" or "user", offset is line number.
+func (s *JSONLStore) List(cursor string, limit int) ([]Prompt, string, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ if limit <= 0 {
+ limit = 100 // Default limit
+ }
+
+ // Load all prompts from both files
+ allPrompts, err := s.loadAllPrompts()
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Sort by name for consistent ordering
+ sort.Slice(allPrompts, func(i, j int) bool {
+ return allPrompts[i].Name < allPrompts[j].Name
+ })
+
+ // Handle pagination
+ startIdx := 0
+ if cursor != "" {
+ // Simple cursor: index as string
+ fmt.Sscanf(cursor, "%d", &startIdx)
+ }
+
+ if startIdx >= len(allPrompts) {
+ return []Prompt{}, "", nil
+ }
+
+ endIdx := startIdx + limit
+ if endIdx > len(allPrompts) {
+ endIdx = len(allPrompts)
+ }
+
+ result := allPrompts[startIdx:endIdx]
+ nextCursor := ""
+ if endIdx < len(allPrompts) {
+ nextCursor = fmt.Sprintf("%d", endIdx)
+ }
+
+ return result, nextCursor, nil
+}
+
+// Get retrieves a prompt by name.
+func (s *JSONLStore) Get(name string) (*Prompt, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ allPrompts, err := s.loadAllPrompts()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, p := range allPrompts {
+ if p.Name == name {
+ return &p, nil
+ }
+ }
+
+ return nil, fmt.Errorf("prompt not found: %s", name)
+}
+
+// Create adds a new prompt to user.jsonl.
+func (s *JSONLStore) Create(prompt *Prompt) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Backup before write
+ if err := s.backupUserPrompts(); err != nil {
+ return fmt.Errorf("backup failed: %w", err)
+ }
+
+ // Check if prompt already exists (use internal method to avoid deadlock)
+ allPrompts, err := s.loadAllPrompts()
+ if err != nil {
+ return fmt.Errorf("load prompts: %w", err)
+ }
+ for _, p := range allPrompts {
+ if p.Name == prompt.Name {
+ return fmt.Errorf("prompt already exists: %s", prompt.Name)
+ }
+ }
+
+ // Append to user.jsonl
+ userPath := filepath.Join(s.dataDir, "user.jsonl")
+ data, err := json.Marshal(prompt)
+ if err != nil {
+ return fmt.Errorf("marshal prompt: %w", err)
+ }
+ data = append(data, '\n')
+
+ f, err := os.OpenFile(userPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("open user.jsonl: %w", err)
+ }
+ defer f.Close()
+
+ if _, err := f.Write(data); err != nil {
+ return fmt.Errorf("write user.jsonl: %w", err)
+ }
+
+ return nil
+}
+
+// Update modifies an existing prompt in user.jsonl.
+// Note: This rewrites the entire user.jsonl file.
+func (s *JSONLStore) Update(prompt *Prompt) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Backup before write
+ if err := s.backupUserPrompts(); err != nil {
+ return fmt.Errorf("backup failed: %w", err)
+ }
+
+ // Load user prompts
+ userPrompts, err := s.loadPromptsFromFile("user.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ // Find and update prompt
+ found := false
+ for i, p := range userPrompts {
+ if p.Name == prompt.Name {
+ userPrompts[i] = *prompt
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ return fmt.Errorf("prompt not found in user.jsonl: %s", prompt.Name)
+ }
+
+ // Rewrite user.jsonl
+ return s.writePromptsToFile("user.jsonl", userPrompts)
+}
+
+// Delete removes a prompt from user.jsonl.
+func (s *JSONLStore) Delete(name string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Backup before write
+ if err := s.backupUserPrompts(); err != nil {
+ return fmt.Errorf("backup failed: %w", err)
+ }
+
+ // Load user prompts
+ userPrompts, err := s.loadPromptsFromFile("user.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ // Filter out deleted prompt
+ var filtered []Prompt
+ found := false
+ for _, p := range userPrompts {
+ if p.Name != name {
+ filtered = append(filtered, p)
+ } else {
+ found = true
+ }
+ }
+
+ if !found {
+ return fmt.Errorf("prompt not found: %s", name)
+ }
+
+ // Rewrite user.jsonl
+ return s.writePromptsToFile("user.jsonl", filtered)
+}
+
+// SearchByTags finds prompts matching all given tags.
+func (s *JSONLStore) SearchByTags(tags []string) ([]Prompt, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ allPrompts, err := s.loadAllPrompts()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(tags) == 0 {
+ return allPrompts, nil
+ }
+
+ var results []Prompt
+ for _, p := range allPrompts {
+ if s.hasAllTags(p.Tags, tags) {
+ results = append(results, p)
+ }
+ }
+
+ return results, nil
+}
+
+// hasAllTags checks if promptTags contains all searchTags.
+func (s *JSONLStore) hasAllTags(promptTags, searchTags []string) bool {
+ tagSet := make(map[string]bool)
+ for _, t := range promptTags {
+ tagSet[t] = true
+ }
+
+ for _, t := range searchTags {
+ if !tagSet[t] {
+ return false
+ }
+ }
+ return true
+}
+
+// loadAllPrompts loads prompts from both default.jsonl and user.jsonl.
+func (s *JSONLStore) loadAllPrompts() ([]Prompt, error) {
+ defaultPrompts, err := s.loadPromptsFromFile("default.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ userPrompts, err := s.loadPromptsFromFile("user.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ // Merge (user prompts override built-ins by name)
+ promptMap := make(map[string]Prompt)
+ for _, p := range defaultPrompts {
+ promptMap[p.Name] = p
+ }
+ for _, p := range userPrompts {
+ promptMap[p.Name] = p
+ }
+
+ var all []Prompt
+ for _, p := range promptMap {
+ all = append(all, p)
+ }
+
+ return all, nil
+}
+
+// loadPromptsFromFile reads prompts from a JSONL file.
+func (s *JSONLStore) loadPromptsFromFile(filename string) ([]Prompt, error) {
+ path := filepath.Join(s.dataDir, filename)
+ data, err := s.readFileFn(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var prompts []Prompt
+ lines := splitLines(data)
+ for i, line := range lines {
+ if len(line) == 0 {
+ continue
+ }
+
+ var p Prompt
+ if err := json.Unmarshal(line, &p); err != nil {
+ // Log error but continue parsing
+ fmt.Fprintf(os.Stderr, "warning: cannot parse prompt at %s:%d: %v\n", filename, i+1, err)
+ continue
+ }
+ prompts = append(prompts, p)
+ }
+
+ return prompts, nil
+}
+
+// writePromptsToFile writes prompts to a JSONL file.
+func (s *JSONLStore) writePromptsToFile(filename string, prompts []Prompt) error {
+ path := filepath.Join(s.dataDir, filename)
+
+ var lines []byte
+ for _, p := range prompts {
+ data, err := json.Marshal(p)
+ if err != nil {
+ return fmt.Errorf("marshal prompt: %w", err)
+ }
+ lines = append(lines, data...)
+ lines = append(lines, '\n')
+ }
+
+ if err := s.writeFileFn(path, lines, 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", filename, err)
+ }
+
+ return nil
+}
+
+// splitLines splits data into lines (handles both \n and \r\n).
+// Copied from tmuxedit/history.go pattern.
+func splitLines(data []byte) [][]byte {
+ var lines [][]byte
+ start := 0
+ for i := 0; i < len(data); i++ {
+ if data[i] == '\n' {
+ end := i
+ if end > start && data[end-1] == '\r' {
+ end--
+ }
+ lines = append(lines, data[start:end])
+ start = i + 1
+ }
+ }
+ if start < len(data) {
+ lines = append(lines, data[start:])
+ }
+ return lines
+}
+
+// backupUserPrompts creates a timestamped backup of user.jsonl before any write operation.
+// Automatically manages backup retention based on maxBackups setting.
+func (s *JSONLStore) backupUserPrompts() error {
+ userPath := filepath.Join(s.dataDir, "user.jsonl")
+
+ // Check if user.jsonl exists
+ if _, err := os.Stat(userPath); os.IsNotExist(err) {
+ return nil // No file to backup
+ }
+
+ // Read current user.jsonl
+ data, err := s.readFileFn(userPath)
+ if err != nil {
+ return fmt.Errorf("read user.jsonl: %w", err)
+ }
+
+ // Create backup with timestamp
+ timestamp := time.Now().Format("20060102-150405")
+ backupDir := filepath.Join(s.dataDir, "backups")
+ backupPath := filepath.Join(backupDir, fmt.Sprintf("user.jsonl.%s", timestamp))
+
+ // Write backup
+ if err := s.writeFileFn(backupPath, data, 0o644); err != nil {
+ return fmt.Errorf("write backup: %w", err)
+ }
+
+ // Clean old backups if maxBackups is set
+ if s.maxBackups > 0 {
+ if err := s.cleanOldBackups(); err != nil {
+ // Log but don't fail - backup succeeded
+ fmt.Fprintf(os.Stderr, "warning: failed to clean old backups: %v\n", err)
+ }
+ }
+
+ return nil
+}
+
+// cleanOldBackups removes old backup files, keeping only the most recent maxBackups.
+func (s *JSONLStore) cleanOldBackups() error {
+ backupDir := filepath.Join(s.dataDir, "backups")
+
+ // List all backups
+ entries, err := os.ReadDir(backupDir)
+ if err != nil {
+ return fmt.Errorf("read backup dir: %w", err)
+ }
+
+ // Filter backup files
+ var backups []string
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasPrefix(entry.Name(), "user.jsonl.") {
+ backups = append(backups, entry.Name())
+ }
+ }
+
+ // Sort backups (newest last due to timestamp format)
+ sort.Strings(backups)
+
+ // Remove old backups
+ if len(backups) > s.maxBackups {
+ toRemove := len(backups) - s.maxBackups
+ for i := 0; i < toRemove; i++ {
+ backupPath := filepath.Join(backupDir, backups[i])
+ if err := os.Remove(backupPath); err != nil {
+ return fmt.Errorf("remove backup %s: %w", backups[i], err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// ListBackups returns a list of available backup files with timestamps.
+func (s *JSONLStore) ListBackups() ([]string, error) {
+ backupDir := filepath.Join(s.dataDir, "backups")
+
+ entries, err := os.ReadDir(backupDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []string{}, nil
+ }
+ return nil, fmt.Errorf("read backup dir: %w", err)
+ }
+
+ var backups []string
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasPrefix(entry.Name(), "user.jsonl.") {
+ backups = append(backups, entry.Name())
+ }
+ }
+
+ // Sort newest first
+ sort.Sort(sort.Reverse(sort.StringSlice(backups)))
+
+ return backups, nil
+}
+
+// RestoreBackup restores user.jsonl from a backup file.
+func (s *JSONLStore) RestoreBackup(backupName string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ backupPath := filepath.Join(s.dataDir, "backups", backupName)
+ userPath := filepath.Join(s.dataDir, "user.jsonl")
+
+ // Read backup
+ data, err := s.readFileFn(backupPath)
+ if err != nil {
+ return fmt.Errorf("read backup: %w", err)
+ }
+
+ // Create a safety backup of current state before restore
+ if _, err := os.Stat(userPath); err == nil {
+ timestamp := time.Now().Format("20060102-150405")
+ safetyBackup := filepath.Join(s.dataDir, "backups", fmt.Sprintf("user.jsonl.%s.pre-restore", timestamp))
+ currentData, _ := s.readFileFn(userPath)
+ s.writeFileFn(safetyBackup, currentData, 0o644)
+ }
+
+ // Restore from backup
+ if err := s.writeFileFn(userPath, data, 0o644); err != nil {
+ return fmt.Errorf("write restored user.jsonl: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/promptstore/store_test.go b/internal/promptstore/store_test.go
new file mode 100644
index 0000000..8dd506a
--- /dev/null
+++ b/internal/promptstore/store_test.go
@@ -0,0 +1,311 @@
+// Summary: Tests for prompt store operations
+package promptstore
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestJSONLStore_Get(t *testing.T) {
+ tests := []struct {
+ name string
+ promptName string
+ want *Prompt
+ wantErr bool
+ }{
+ {
+ name: "get built-in prompt",
+ promptName: "code_review",
+ want: &Prompt{
+ Name: "code_review",
+ Title: "Request Code Review",
+ Description: "Analyzes code quality, style, and suggests improvements",
+ },
+ wantErr: false,
+ },
+ {
+ name: "prompt not found",
+ promptName: "nonexistent",
+ want: nil,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ got, err := store.Get(tt.promptName)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr {
+ if got.Name != tt.want.Name {
+ t.Errorf("Get() name = %v, want %v", got.Name, tt.want.Name)
+ }
+ if got.Title != tt.want.Title {
+ t.Errorf("Get() title = %v, want %v", got.Title, tt.want.Title)
+ }
+ }
+ })
+ }
+}
+
+func TestJSONLStore_List(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // List all prompts
+ prompts, cursor, err := store.List("", 100)
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+
+ // Should have built-in prompts
+ if len(prompts) < 5 {
+ t.Errorf("List() got %d prompts, want at least 5", len(prompts))
+ }
+
+ // No cursor for full list
+ if cursor != "" {
+ t.Errorf("List() cursor = %v, want empty", cursor)
+ }
+
+ // Test pagination
+ prompts1, cursor1, err := store.List("", 3)
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ if len(prompts1) != 3 {
+ t.Errorf("List() got %d prompts, want 3", len(prompts1))
+ }
+ if cursor1 == "" {
+ t.Error("List() expected cursor, got empty")
+ }
+
+ // Get next page
+ prompts2, cursor2, err := store.List(cursor1, 3)
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ if len(prompts2) == 0 {
+ t.Error("List() second page empty")
+ }
+ if cursor2 == "" && len(prompts) > 6 {
+ t.Error("List() expected cursor2, got empty")
+ }
+}
+
+func TestJSONLStore_Create(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ now := time.Now()
+ prompt := &Prompt{
+ Name: "test_prompt",
+ Title: "Test Prompt",
+ Description: "A test prompt",
+ Arguments: []PromptArgument{
+ {Name: "input", Description: "Test input", Required: true},
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: "Test: {{input}}",
+ },
+ },
+ },
+ Tags: []string{"test"},
+ Created: now,
+ Updated: now,
+ }
+
+ // Create prompt
+ if err := store.Create(prompt); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ // Verify it exists
+ got, err := store.Get("test_prompt")
+ if err != nil {
+ t.Fatalf("Get() after Create() error = %v", err)
+ }
+ if got.Name != prompt.Name {
+ t.Errorf("Get() name = %v, want %v", got.Name, prompt.Name)
+ }
+
+ // Try to create duplicate
+ err = store.Create(prompt)
+ if err == nil {
+ t.Error("Create() duplicate should fail")
+ }
+}
+
+func TestJSONLStore_Update(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ now := time.Now()
+ prompt := &Prompt{
+ Name: "test_update",
+ Title: "Original Title",
+ Description: "Original description",
+ Messages: []PromptMessage{},
+ Created: now,
+ Updated: now,
+ }
+
+ // Create initial prompt
+ if err := store.Create(prompt); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ // Update prompt
+ prompt.Title = "Updated Title"
+ prompt.Description = "Updated description"
+ if err := store.Update(prompt); err != nil {
+ t.Fatalf("Update() error = %v", err)
+ }
+
+ // Verify update
+ got, err := store.Get("test_update")
+ if err != nil {
+ t.Fatalf("Get() after Update() error = %v", err)
+ }
+ if got.Title != "Updated Title" {
+ t.Errorf("Get() title = %v, want Updated Title", got.Title)
+ }
+}
+
+func TestJSONLStore_Delete(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ now := time.Now()
+ prompt := &Prompt{
+ Name: "test_delete",
+ Title: "Delete Me",
+ Description: "Will be deleted",
+ Messages: []PromptMessage{},
+ Created: now,
+ Updated: now,
+ }
+
+ // Create prompt
+ if err := store.Create(prompt); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ // Delete prompt
+ if err := store.Delete("test_delete"); err != nil {
+ t.Fatalf("Delete() error = %v", err)
+ }
+
+ // Verify deletion
+ _, err = store.Get("test_delete")
+ if err == nil {
+ t.Error("Get() after Delete() should fail")
+ }
+}
+
+func TestJSONLStore_SearchByTags(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Search for development tag
+ results, err := store.SearchByTags([]string{"development"})
+ if err != nil {
+ t.Fatalf("SearchByTags() error = %v", err)
+ }
+
+ if len(results) < 3 {
+ t.Errorf("SearchByTags() got %d results, want at least 3", len(results))
+ }
+
+ // Search for multiple tags
+ results, err = store.SearchByTags([]string{"development", "review"})
+ if err != nil {
+ t.Fatalf("SearchByTags() error = %v", err)
+ }
+
+ // Should find code_review prompt
+ found := false
+ for _, p := range results {
+ if p.Name == "code_review" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("SearchByTags() should find code_review prompt")
+ }
+}
+
+func TestBuiltinPrompts(t *testing.T) {
+ prompts := GetBuiltinPrompts()
+
+ if len(prompts) < 5 {
+ t.Errorf("GetBuiltinPrompts() got %d prompts, want at least 5", len(prompts))
+ }
+
+ // Verify each prompt has required fields
+ for _, p := range prompts {
+ if p.Name == "" {
+ t.Error("Prompt missing name")
+ }
+ if p.Title == "" {
+ t.Errorf("Prompt %s missing title", p.Name)
+ }
+ if len(p.Messages) == 0 {
+ t.Errorf("Prompt %s has no messages", p.Name)
+ }
+ }
+}
+
+func TestJSONLStore_DefaultFileCreation(t *testing.T) {
+ tmpDir := t.TempDir()
+ _, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Verify default.jsonl was created
+ defaultPath := filepath.Join(tmpDir, "default.jsonl")
+ if _, err := os.Stat(defaultPath); os.IsNotExist(err) {
+ t.Error("default.jsonl was not created")
+ }
+
+ // Verify it contains prompts
+ data, err := os.ReadFile(defaultPath)
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+
+ if len(data) == 0 {
+ t.Error("default.jsonl is empty")
+ }
+}
diff --git a/internal/promptstore/types.go b/internal/promptstore/types.go
new file mode 100644
index 0000000..56aa99b
--- /dev/null
+++ b/internal/promptstore/types.go
@@ -0,0 +1,39 @@
+// Summary: Data models for prompt storage (templates with arguments).
+package promptstore
+
+import "time"
+
+// Prompt represents a reusable prompt template with arguments.
+// Prompts are stored in JSONL format and can be rendered with user-provided arguments.
+type Prompt struct {
+ Name string `json:"name"` // Unique identifier (alphanumeric + underscores)
+ Title string `json:"title"` // Display name
+ Description string `json:"description"` // Human-readable description
+ Arguments []PromptArgument `json:"arguments"` // Template variables
+ Messages []PromptMessage `json:"messages"` // Conversation messages
+ Tags []string `json:"tags"` // Categorization tags
+ Created time.Time `json:"created"` // Creation timestamp
+ Updated time.Time `json:"updated"` // Last update timestamp
+}
+
+// PromptArgument defines a template variable that can be substituted in messages.
+// Used for parameterized prompts like "review {{code}}" where {{code}} is an argument.
+type PromptArgument struct {
+ Name string `json:"name"` // Variable name (used in {{name}})
+ Description string `json:"description"` // Human-readable description
+ Required bool `json:"required"` // Whether argument is required
+}
+
+// PromptMessage represents a single message in a conversation.
+// Messages can be from user or assistant roles and contain text content.
+type PromptMessage struct {
+ Role string `json:"role"` // "user" or "assistant"
+ Content MessageContent `json:"content"` // Message content
+}
+
+// MessageContent contains the actual message data.
+// Currently supports text content; extensible for images/resources in future.
+type MessageContent struct {
+ Type string `json:"type"` // "text", "image", "resource"
+ Text string `json:"text,omitempty"`
+}