// Package scratchpad manages the project-scoped markdown files described // in SPEC §3. Files live under // $XDG_DATA_HOME/patterm/projects//scratchpads/. Last-write- // wins with a revision token (SPEC §14). package scratchpad import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path/filepath" "sort" "strings" ) // Store is the per-project scratchpad directory. type Store struct { dir string } // Open returns a Store rooted at the SPEC §3 path for projectKey. func Open(projectKey string) (*Store, error) { base, err := DataDir() if err != nil { return nil, err } dir := filepath.Join(base, "projects", projectKey, "scratchpads") if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("scratchpad: mkdir %s: %w", dir, err) } return &Store{dir: dir}, nil } // DataDir resolves $XDG_DATA_HOME/patterm with the conventional fallback. func DataDir() (string, error) { if h := os.Getenv("XDG_DATA_HOME"); h != "" { return filepath.Join(h, "patterm"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".local", "share", "patterm"), nil } // Entry is what List returns; SPEC §7 `scratchpad_list` shape. type Entry struct { Name string Size int64 ModifiedAt string // RFC3339; kept as string so MCP serialization is trivial later } func (s *Store) Dir() string { return s.dir } func (s *Store) List() ([]Entry, error) { entries, err := os.ReadDir(s.dir) if err != nil { return nil, err } out := make([]Entry, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } info, err := e.Info() if err != nil { continue } out = append(out, Entry{ Name: e.Name(), Size: info.Size(), ModifiedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"), }) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } func (s *Store) Read(name string) (content string, revision string, err error) { p, err := s.safePath(name) if err != nil { return "", "", err } b, err := os.ReadFile(p) if err != nil { return "", "", err } return string(b), revisionOf(b), nil } // RevisionMismatchError is returned by Write when expectedRevision // doesn't match the on-disk revision. The MCP scratchpad_write tool // surfaces CurrentRevision as `current_revision` in the response so // the caller can re-read and merge (SPEC §7 / §14). type RevisionMismatchError struct { CurrentRevision string } func (e *RevisionMismatchError) Error() string { return "scratchpad: revision mismatch (current=" + e.CurrentRevision + ")" } // Write replaces the file's contents. expectedRevision, if non-empty, // must match the current revision or the write is rejected with a // *RevisionMismatchError (SPEC §14 last-write-wins-with-token). func (s *Store) Write(name, content, expectedRevision string) (string, error) { p, err := s.safePath(name) if err != nil { return "", err } if expectedRevision != "" { if cur, err := os.ReadFile(p); err == nil { curRev := revisionOf(cur) if curRev != expectedRevision { return "", &RevisionMismatchError{CurrentRevision: curRev} } } } if err := os.WriteFile(p, []byte(content), 0o600); err != nil { return "", err } return revisionOf([]byte(content)), nil } func (s *Store) Append(name, content string) error { p, err := s.safePath(name) if err != nil { return err } f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return err } defer f.Close() _, err = f.WriteString(content) return err } func (s *Store) safePath(name string) (string, error) { if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { return "", errors.New("scratchpad: invalid name") } return filepath.Join(s.dir, name), nil } func revisionOf(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:6]) }