Ryanhub - file viewer
filename: assistant/tools/scratchpad.go
branch: main
back to repo
package tools

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"sync"
	"unicode/utf8"
)

const scratchpadMaxReadRunes = 120_000

var scratchpadMu sync.Mutex

var scratchpadReadParams = json.RawMessage(`{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}`)

var scratchpadWriteParams = json.RawMessage(`{
  "type": "object",
  "properties": {
    "content": { "type": "string", "description": "Markdown to append: a newline is written first, then this text at the end of the file." }
  },
  "required": ["content"],
  "additionalProperties": false
}`)

type scratchpadWriteArgs struct {
	Content string `json:"content"`
}

func readScratchpadFile(path string) (string, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return "", nil
		}
		return "", err
	}
	if !utf8.Valid(b) {
		return "", fmt.Errorf("scratchpad is not valid UTF-8")
	}
	return string(b), nil
}

func writeScratchpadFile(path string, content string) error {
	return os.WriteFile(path, []byte(content), 0o644)
}

// ReadScratchpadLocked reads the scratchpad file using the same lock as other scratchpad I/O.
func ReadScratchpadLocked(absPath string) (string, error) {
	scratchpadMu.Lock()
	defer scratchpadMu.Unlock()
	return readScratchpadFile(absPath)
}

// WriteScratchpadLocked overwrites the entire scratchpad file (e.g. web UI save).
func WriteScratchpadLocked(absPath string, content string) error {
	scratchpadMu.Lock()
	defer scratchpadMu.Unlock()
	return writeScratchpadFile(absPath, content)
}

func appendNewlineThenContent(prev, content string) string {
	if strings.TrimSpace(prev) == "" {
		return content
	}
	return strings.TrimRight(prev, "\r\n") + "\n" + content
}

func scratchpadTools(absPath string) []Tool {
	descRead := "Read the shared Markdown scratchpad file. Call this when the user refers to the scratchpad or you need the latest text."
	descWrite := "Append Markdown to the end of the shared scratchpad. The server adds a single newline, then your content (it does not replace the file)."
	return []Tool{
		{
			Name:        "scratchpad_read",
			Description: descRead,
			Parameters:  scratchpadReadParams,
			Run: func(ctx context.Context, args json.RawMessage) (string, error) {
				scratchpadMu.Lock()
				defer scratchpadMu.Unlock()
				text, err := readScratchpadFile(absPath)
				if err != nil {
					return "", err
				}
				if strings.TrimSpace(text) == "" {
					return "(Scratchpad is empty.)", nil
				}
				if utf8.RuneCountInString(text) > scratchpadMaxReadRunes {
					runes := []rune(text)
					head := string(runes[:scratchpadMaxReadRunes])
					return fmt.Sprintf("%s\n\n[Truncated: file is longer than %d characters; ask the user to split content or edit in the UI.]",
						head, scratchpadMaxReadRunes), nil
				}
				return text, nil
			},
		},
		{
			Name:        "scratchpad_write",
			Description: descWrite,
			Parameters:  scratchpadWriteParams,
			Run: func(ctx context.Context, args json.RawMessage) (string, error) {
				var a scratchpadWriteArgs
				if err := json.Unmarshal(args, &a); err != nil {
					return "", err
				}
				scratchpadMu.Lock()
				defer scratchpadMu.Unlock()
				prev, err := readScratchpadFile(absPath)
				if err != nil {
					return "", err
				}
				out := appendNewlineThenContent(prev, a.Content)
				if err := writeScratchpadFile(absPath, out); err != nil {
					return "", err
				}
				return fmt.Sprintf("Appended to scratchpad (file now %d characters).", utf8.RuneCountInString(out)), nil
			},
		},
	}
}