From ace371620499b1826f566ac9dc8d8adc15fff0c9 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 5 Apr 2026 21:38:45 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20persist.Store=20=E2=80=94=20session-sco?= =?UTF-8?q?ped=20/tmp=20tool=20result=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tool/persist/store.go | 138 ++++++++++++++++++++++++++++ internal/tool/persist/store_test.go | 88 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 internal/tool/persist/store.go create mode 100644 internal/tool/persist/store_test.go diff --git a/internal/tool/persist/store.go b/internal/tool/persist/store.go new file mode 100644 index 0000000..5371a9c --- /dev/null +++ b/internal/tool/persist/store.go @@ -0,0 +1,138 @@ +package persist + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + minPersistSize = 1024 // bytes: results smaller than this are not persisted + previewSize = 2000 // chars shown inline in the LLM context +) + +// ResultFile describes a persisted tool result. +type ResultFile struct { + Path string + ToolName string + CallID string + Size int64 + ModTime time.Time +} + +// Store persists tool results to /tmp for cross-tool session sharing. +type Store struct { + dir string // /tmp/gnoma-/tool-results +} + +// New creates a Store for the given session ID. +// The directory is created on first Save. +func New(sessionID string) *Store { + return &Store{ + dir: filepath.Join("/tmp", "gnoma-"+sessionID, "tool-results"), + } +} + +// Dir returns the absolute path to the tool-results directory. +func (s *Store) Dir() string { return s.dir } + +// Save writes content to disk if len(content) >= minPersistSize. +// Returns (filePath, true) on persistence, ("", false) if content is too small. +func (s *Store) Save(toolName, callID, content string) (string, bool) { + if len(content) < minPersistSize { + return "", false + } + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return "", false + } + // Sanitize tool name for filesystem (replace dots and slashes) + safeName := strings.NewReplacer(".", "_", "/", "_").Replace(toolName) + filename := safeName + "-" + callID + ".txt" + path := filepath.Join(s.dir, filename) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return "", false + } + return path, true +} + +// InlineReplacement returns the string that replaces the full content in LLM context. +func InlineReplacement(path, content string) string { + preview := content + if len([]rune(preview)) > previewSize { + preview = string([]rune(preview)[:previewSize]) + } + return fmt.Sprintf("[Tool result saved: %s]\n\nPreview (first %d chars):\n%s", + path, previewSize, preview) +} + +// List returns all persisted results, optionally filtered by tool name prefix. +// An empty filter returns all results. +func (s *Store) List(toolNameFilter string) ([]ResultFile, error) { + entries, err := os.ReadDir(s.dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var results []ResultFile + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".txt") { + continue + } + toolName, callID, ok := parseFilename(e.Name()) + if !ok { + continue + } + if toolNameFilter != "" && !strings.HasPrefix(toolName, toolNameFilter) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + results = append(results, ResultFile{ + Path: filepath.Join(s.dir, e.Name()), + ToolName: toolName, + CallID: callID, + Size: info.Size(), + ModTime: info.ModTime(), + }) + } + return results, nil +} + +// Read returns the content of a persisted result file. +// Returns an error if path is outside the session's tool-results directory. +func (s *Store) Read(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("persist: invalid path: %w", err) + } + if !strings.HasPrefix(abs, s.dir+string(filepath.Separator)) && abs != s.dir { + return "", fmt.Errorf("persist: path %q is outside session directory", path) + } + data, err := os.ReadFile(abs) + if err != nil { + return "", err + } + return string(data), nil +} + +// parseFilename extracts toolName and callID from "-.txt". +// Tool names may contain underscores (dots were replaced at save time). +func parseFilename(name string) (toolName, callID string, ok bool) { + name = strings.TrimSuffix(name, ".txt") + for _, sep := range []string{"-toolu_", "-call-", "-tool_"} { + if idx := strings.LastIndex(name, sep); idx > 0 { + return name[:idx], name[idx+1:], true + } + } + // Fallback: split on first dash + if idx := strings.Index(name, "-"); idx > 0 { + return name[:idx], name[idx+1:], true + } + return name, "", true +} diff --git a/internal/tool/persist/store_test.go b/internal/tool/persist/store_test.go new file mode 100644 index 0000000..702ab56 --- /dev/null +++ b/internal/tool/persist/store_test.go @@ -0,0 +1,88 @@ +package persist_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "somegit.dev/Owlibou/gnoma/internal/tool/persist" +) + +func TestStore_SaveSkipsSmallContent(t *testing.T) { + s := persist.New("test-session-001") + t.Cleanup(func() { os.RemoveAll(s.Dir()) }) + + path, ok := s.Save("bash", "call-001", "small output") + if ok { + t.Errorf("expected not persisted, got path %q", path) + } + if path != "" { + t.Errorf("expected empty path for small content") + } +} + +func TestStore_SavePersistsLargeContent(t *testing.T) { + s := persist.New("test-session-002") + t.Cleanup(func() { os.RemoveAll(s.Dir()) }) + + content := strings.Repeat("x", 1024) + path, ok := s.Save("fs.grep", "call-002", content) + if !ok { + t.Fatal("expected content to be persisted") + } + if !strings.HasSuffix(path, "fs_grep-call-002.txt") { + t.Errorf("unexpected path: %q", path) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("file not written: %v", err) + } + if string(got) != content { + t.Error("file content mismatch") + } +} + +func TestStore_ListFilters(t *testing.T) { + s := persist.New("test-session-003") + t.Cleanup(func() { os.RemoveAll(s.Dir()) }) + + bigContent := strings.Repeat("y", 1024) + s.Save("bash", "c1", bigContent) + s.Save("fs.read", "c2", bigContent) + s.Save("bash", "c3", bigContent) + + all, err := s.List("") + if err != nil { + t.Fatal(err) + } + if len(all) != 3 { + t.Errorf("want 3 results, got %d", len(all)) + } + + filtered, err := s.List("bash") + if err != nil { + t.Fatal(err) + } + if len(filtered) != 2 { + t.Errorf("want 2 bash results, got %d", len(filtered)) + } +} + +func TestStore_ReadValidatesPath(t *testing.T) { + s := persist.New("test-session-004") + t.Cleanup(func() { os.RemoveAll(s.Dir()) }) + + // Path outside session dir must be rejected + _, err := s.Read("/etc/passwd") + if err == nil { + t.Error("expected error for path outside session dir") + } + + // Valid path (even if file doesn't exist) should pass validation + _, err = s.Read(filepath.Join(s.Dir(), "bash-call.txt")) + // os.ErrNotExist is fine — path was valid + if err != nil && !os.IsNotExist(err) { + t.Errorf("unexpected error for valid path: %v", err) + } +}