[READ-ONLY] Mirror of https://github.com/just-cameron/note. External storage tool for Letta agents to manage persistent notes
0

Configure Feed

Select the types of activity you want to include in your feed.

Add note-sync CLI tool with web UI

Go CLI for bidirectional sync between Letta notes and local markdown files.

Features:
- init, pull, push, status commands
- agents command with name search
- Web UI with Tiptap rich text editor
- Markdown round-trip via marked + turndown

🤖 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

+1703
+9
.gitignore
··· 18 18 # Letta 19 19 .letta/ 20 20 21 + # Go 22 + bin/ 23 + /note-sync 24 + /note-sync-* 25 + 26 + # Test directories 27 + loop-11/ 28 + test-sync/ 29 + 21 30 # OS 22 31 .DS_Store 23 32 Thumbs.db
+34
Makefile
··· 1 + .PHONY: build clean test release 2 + 3 + # Binary name 4 + BINARY=note-sync 5 + 6 + # Build directory 7 + BUILD_DIR=bin 8 + 9 + build: 10 + go build -o $(BUILD_DIR)/$(BINARY) ./cmd/note-sync 11 + 12 + clean: 13 + rm -rf $(BUILD_DIR) 14 + 15 + test: 16 + go test ./... 17 + 18 + # Cross-compile for multiple platforms 19 + release: clean 20 + mkdir -p $(BUILD_DIR) 21 + GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY)-darwin-amd64 ./cmd/note-sync 22 + GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY)-darwin-arm64 ./cmd/note-sync 23 + GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY)-linux-amd64 ./cmd/note-sync 24 + GOOS=linux GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY)-linux-arm64 ./cmd/note-sync 25 + GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY)-windows-amd64.exe ./cmd/note-sync 26 + 27 + # Install locally 28 + install: build 29 + cp $(BUILD_DIR)/$(BINARY) /usr/local/bin/ 30 + 31 + # Download dependencies 32 + deps: 33 + go mod download 34 + go mod tidy
+343
cmd/note-sync/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/cpfiffer/note/internal/api" 8 + "github.com/cpfiffer/note/internal/sync" 9 + "github.com/cpfiffer/note/internal/web" 10 + "github.com/spf13/cobra" 11 + ) 12 + 13 + var ( 14 + forceFlag bool 15 + dirFlag string 16 + nameFlag string 17 + portFlag string 18 + ) 19 + 20 + func main() { 21 + rootCmd := &cobra.Command{ 22 + Use: "note-sync", 23 + Short: "Sync Letta notes with local markdown files", 24 + Long: `A CLI tool to bidirectionally sync Letta memory blocks with a local folder of markdown files.`, 25 + } 26 + 27 + // Init command 28 + initCmd := &cobra.Command{ 29 + Use: "init <agent_id>", 30 + Short: "Initialize a sync directory for an agent", 31 + Args: cobra.ExactArgs(1), 32 + RunE: runInit, 33 + } 34 + initCmd.Flags().StringVar(&dirFlag, "dir", ".", "Directory to initialize") 35 + rootCmd.AddCommand(initCmd) 36 + 37 + // Pull command 38 + pullCmd := &cobra.Command{ 39 + Use: "pull", 40 + Short: "Download notes from Letta to local files", 41 + RunE: runPull, 42 + } 43 + pullCmd.Flags().BoolVar(&forceFlag, "force", false, "Overwrite local changes without conflict check") 44 + rootCmd.AddCommand(pullCmd) 45 + 46 + // Push command 47 + pushCmd := &cobra.Command{ 48 + Use: "push", 49 + Short: "Upload local files to Letta", 50 + RunE: runPush, 51 + } 52 + pushCmd.Flags().BoolVar(&forceFlag, "force", false, "Overwrite remote changes without conflict check") 53 + rootCmd.AddCommand(pushCmd) 54 + 55 + // Status command 56 + statusCmd := &cobra.Command{ 57 + Use: "status", 58 + Short: "Show sync status", 59 + RunE: runStatus, 60 + } 61 + rootCmd.AddCommand(statusCmd) 62 + 63 + // Agents command 64 + agentsCmd := &cobra.Command{ 65 + Use: "agents", 66 + Short: "List available agents", 67 + RunE: runAgents, 68 + } 69 + agentsCmd.Flags().StringVar(&nameFlag, "name", "", "Search agents by name") 70 + rootCmd.AddCommand(agentsCmd) 71 + 72 + // Serve command (web UI) 73 + serveCmd := &cobra.Command{ 74 + Use: "serve", 75 + Short: "Start web UI for browsing and editing notes", 76 + RunE: runServe, 77 + } 78 + serveCmd.Flags().StringVar(&portFlag, "port", "8080", "Port to listen on") 79 + rootCmd.AddCommand(serveCmd) 80 + 81 + if err := rootCmd.Execute(); err != nil { 82 + os.Exit(1) 83 + } 84 + } 85 + 86 + func runInit(cmd *cobra.Command, args []string) error { 87 + agentID := args[0] 88 + 89 + // Check if already initialized 90 + if _, err := sync.LoadState(dirFlag); err == nil { 91 + return fmt.Errorf("directory already initialized. Delete %s to reinitialize", sync.StateFileName) 92 + } 93 + 94 + // Verify API key works 95 + client, err := api.NewClient("") 96 + if err != nil { 97 + return err 98 + } 99 + 100 + // Try to list blocks to verify agent exists 101 + ownerSearch := fmt.Sprintf("owner:%s", agentID) 102 + _, err = client.ListBlocks(ownerSearch) 103 + if err != nil { 104 + return fmt.Errorf("failed to connect to Letta API: %w", err) 105 + } 106 + 107 + state, err := sync.InitState(dirFlag, agentID, "") 108 + if err != nil { 109 + return err 110 + } 111 + 112 + fmt.Printf("Initialized note-sync for agent %s\n", state.AgentID) 113 + fmt.Printf("State file: %s/%s\n", dirFlag, sync.StateFileName) 114 + fmt.Println("\nNext steps:") 115 + fmt.Println(" note-sync pull # Download notes from Letta") 116 + fmt.Println(" note-sync push # Upload local changes") 117 + fmt.Println(" note-sync status # Check sync status") 118 + 119 + return nil 120 + } 121 + 122 + func runPull(cmd *cobra.Command, args []string) error { 123 + dir, err := os.Getwd() 124 + if err != nil { 125 + return err 126 + } 127 + 128 + state, err := sync.LoadState(dir) 129 + if err != nil { 130 + return err 131 + } 132 + 133 + client, err := api.NewClient(state.LettaBaseURL) 134 + if err != nil { 135 + return err 136 + } 137 + 138 + opts := sync.PullOptions{ 139 + Force: forceFlag, 140 + Dir: dir, 141 + } 142 + 143 + result, err := sync.Pull(client, state, opts) 144 + if err != nil { 145 + return err 146 + } 147 + 148 + // Save updated state 149 + if err := state.SaveState(dir); err != nil { 150 + return fmt.Errorf("failed to save state: %w", err) 151 + } 152 + 153 + // Print results 154 + if len(result.Created) > 0 { 155 + fmt.Println("Created:") 156 + for _, p := range result.Created { 157 + fmt.Printf(" + %s\n", p) 158 + } 159 + } 160 + if len(result.Updated) > 0 { 161 + fmt.Println("Updated:") 162 + for _, p := range result.Updated { 163 + fmt.Printf(" ~ %s\n", p) 164 + } 165 + } 166 + if len(result.Conflicts) > 0 { 167 + fmt.Println("Conflicts (see .conflict.md files):") 168 + for _, p := range result.Conflicts { 169 + fmt.Printf(" ! %s\n", p) 170 + } 171 + } 172 + if len(result.Created) == 0 && len(result.Updated) == 0 && len(result.Conflicts) == 0 { 173 + fmt.Println("Already up to date.") 174 + } 175 + 176 + return nil 177 + } 178 + 179 + func runPush(cmd *cobra.Command, args []string) error { 180 + dir, err := os.Getwd() 181 + if err != nil { 182 + return err 183 + } 184 + 185 + state, err := sync.LoadState(dir) 186 + if err != nil { 187 + return err 188 + } 189 + 190 + client, err := api.NewClient(state.LettaBaseURL) 191 + if err != nil { 192 + return err 193 + } 194 + 195 + opts := sync.PushOptions{ 196 + Force: forceFlag, 197 + Dir: dir, 198 + } 199 + 200 + result, err := sync.Push(client, state, opts) 201 + if err != nil { 202 + return err 203 + } 204 + 205 + // Save updated state 206 + if err := state.SaveState(dir); err != nil { 207 + return fmt.Errorf("failed to save state: %w", err) 208 + } 209 + 210 + // Print results 211 + if len(result.Created) > 0 { 212 + fmt.Println("Created:") 213 + for _, p := range result.Created { 214 + fmt.Printf(" + %s\n", p) 215 + } 216 + } 217 + if len(result.Updated) > 0 { 218 + fmt.Println("Updated:") 219 + for _, p := range result.Updated { 220 + fmt.Printf(" ~ %s\n", p) 221 + } 222 + } 223 + if len(result.Conflicts) > 0 { 224 + fmt.Println("Conflicts (pull first to see remote changes):") 225 + for _, p := range result.Conflicts { 226 + fmt.Printf(" ! %s\n", p) 227 + } 228 + } 229 + if len(result.Created) == 0 && len(result.Updated) == 0 && len(result.Conflicts) == 0 { 230 + fmt.Println("Nothing to push.") 231 + } 232 + 233 + return nil 234 + } 235 + 236 + func runStatus(cmd *cobra.Command, args []string) error { 237 + dir, err := os.Getwd() 238 + if err != nil { 239 + return err 240 + } 241 + 242 + state, err := sync.LoadState(dir) 243 + if err != nil { 244 + return err 245 + } 246 + 247 + client, err := api.NewClient(state.LettaBaseURL) 248 + if err != nil { 249 + return err 250 + } 251 + 252 + result, err := sync.Status(client, state, dir) 253 + if err != nil { 254 + return err 255 + } 256 + 257 + hasChanges := false 258 + 259 + if len(result.ModifiedLocally) > 0 { 260 + hasChanges = true 261 + fmt.Println("Modified locally (push to upload):") 262 + for _, p := range result.ModifiedLocally { 263 + fmt.Printf(" M %s\n", p) 264 + } 265 + } 266 + 267 + if len(result.ModifiedRemotely) > 0 { 268 + hasChanges = true 269 + fmt.Println("Modified remotely (pull to download):") 270 + for _, p := range result.ModifiedRemotely { 271 + fmt.Printf(" M %s\n", p) 272 + } 273 + } 274 + 275 + if len(result.Conflicts) > 0 { 276 + hasChanges = true 277 + fmt.Println("Conflicts (both changed):") 278 + for _, p := range result.Conflicts { 279 + fmt.Printf(" ! %s\n", p) 280 + } 281 + } 282 + 283 + if len(result.UntrackedLocal) > 0 { 284 + hasChanges = true 285 + fmt.Println("Untracked local (push to create):") 286 + for _, p := range result.UntrackedLocal { 287 + fmt.Printf(" ? %s\n", p) 288 + } 289 + } 290 + 291 + if len(result.UntrackedRemote) > 0 { 292 + hasChanges = true 293 + fmt.Println("Untracked remote (pull to download):") 294 + for _, p := range result.UntrackedRemote { 295 + fmt.Printf(" ? %s\n", p) 296 + } 297 + } 298 + 299 + if !hasChanges { 300 + fmt.Printf("Everything in sync. (%d files)\n", len(result.Synced)) 301 + } 302 + 303 + return nil 304 + } 305 + 306 + func runAgents(cmd *cobra.Command, args []string) error { 307 + client, err := api.NewClient("") 308 + if err != nil { 309 + return err 310 + } 311 + 312 + agents, err := client.ListAgents(nameFlag) 313 + if err != nil { 314 + return fmt.Errorf("failed to list agents: %w", err) 315 + } 316 + 317 + if len(agents) == 0 { 318 + if nameFlag != "" { 319 + fmt.Printf("No agents found matching name '%s'\n", nameFlag) 320 + } else { 321 + fmt.Println("No agents found") 322 + } 323 + return nil 324 + } 325 + 326 + fmt.Printf("%-40s %s\n", "ID", "NAME") 327 + fmt.Printf("%-40s %s\n", "----", "----") 328 + for _, agent := range agents { 329 + fmt.Printf("%-40s %s\n", agent.ID, agent.Name) 330 + } 331 + 332 + return nil 333 + } 334 + 335 + func runServe(cmd *cobra.Command, args []string) error { 336 + server, err := web.NewServer() 337 + if err != nil { 338 + return err 339 + } 340 + 341 + addr := "localhost:" + portFlag 342 + return server.Start(addr) 343 + }
+10
go.mod
··· 1 + module github.com/cpfiffer/note 2 + 3 + go 1.21 4 + 5 + require github.com/spf13/cobra v1.8.0 6 + 7 + require ( 8 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 9 + github.com/spf13/pflag v1.0.5 // indirect 10 + )
+10
go.sum
··· 1 + github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 3 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 4 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 5 + github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 6 + github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 7 + github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 8 + github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 9 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+206
internal/api/client.go
··· 1 + package api 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/url" 10 + "os" 11 + ) 12 + 13 + // Block represents a Letta memory block 14 + type Block struct { 15 + ID string `json:"id"` 16 + Label string `json:"label"` 17 + Value string `json:"value"` 18 + Description string `json:"description"` 19 + } 20 + 21 + // Client is a Letta API client 22 + type Client struct { 23 + BaseURL string 24 + APIKey string 25 + HTTPClient *http.Client 26 + } 27 + 28 + // NewClient creates a new Letta API client 29 + func NewClient(baseURL string) (*Client, error) { 30 + apiKey := os.Getenv("LETTA_API_KEY") 31 + if apiKey == "" { 32 + return nil, fmt.Errorf("LETTA_API_KEY environment variable not set") 33 + } 34 + 35 + if baseURL == "" { 36 + baseURL = "https://api.letta.com" 37 + } 38 + 39 + return &Client{ 40 + BaseURL: baseURL, 41 + APIKey: apiKey, 42 + HTTPClient: &http.Client{}, 43 + }, nil 44 + } 45 + 46 + // doRequest performs an HTTP request with authentication 47 + func (c *Client) doRequest(method, path string, body interface{}) ([]byte, error) { 48 + var bodyReader io.Reader 49 + if body != nil { 50 + jsonBody, err := json.Marshal(body) 51 + if err != nil { 52 + return nil, fmt.Errorf("failed to marshal request body: %w", err) 53 + } 54 + bodyReader = bytes.NewReader(jsonBody) 55 + } 56 + 57 + req, err := http.NewRequest(method, c.BaseURL+path, bodyReader) 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to create request: %w", err) 60 + } 61 + 62 + req.Header.Set("Authorization", "Bearer "+c.APIKey) 63 + req.Header.Set("Content-Type", "application/json") 64 + 65 + resp, err := c.HTTPClient.Do(req) 66 + if err != nil { 67 + return nil, fmt.Errorf("request failed: %w", err) 68 + } 69 + defer resp.Body.Close() 70 + 71 + respBody, err := io.ReadAll(resp.Body) 72 + if err != nil { 73 + return nil, fmt.Errorf("failed to read response: %w", err) 74 + } 75 + 76 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 77 + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) 78 + } 79 + 80 + return respBody, nil 81 + } 82 + 83 + // ListBlocks lists all blocks matching the description search 84 + func (c *Client) ListBlocks(descriptionSearch string) ([]Block, error) { 85 + path := "/v1/blocks?" 86 + params := url.Values{} 87 + if descriptionSearch != "" { 88 + params.Set("description_search", descriptionSearch) 89 + } 90 + path += params.Encode() 91 + 92 + respBody, err := c.doRequest("GET", path, nil) 93 + if err != nil { 94 + return nil, err 95 + } 96 + 97 + var blocks []Block 98 + if err := json.Unmarshal(respBody, &blocks); err != nil { 99 + return nil, fmt.Errorf("failed to parse blocks: %w", err) 100 + } 101 + 102 + return blocks, nil 103 + } 104 + 105 + // GetBlock retrieves a specific block by label and owner 106 + func (c *Client) GetBlock(label, ownerID string) (*Block, error) { 107 + path := "/v1/blocks?" 108 + params := url.Values{} 109 + params.Set("label", label) 110 + params.Set("description_search", ownerID) 111 + path += params.Encode() 112 + 113 + respBody, err := c.doRequest("GET", path, nil) 114 + if err != nil { 115 + return nil, err 116 + } 117 + 118 + var blocks []Block 119 + if err := json.Unmarshal(respBody, &blocks); err != nil { 120 + return nil, fmt.Errorf("failed to parse blocks: %w", err) 121 + } 122 + 123 + if len(blocks) == 0 { 124 + return nil, nil 125 + } 126 + 127 + return &blocks[0], nil 128 + } 129 + 130 + // CreateBlock creates a new block 131 + func (c *Client) CreateBlock(label, value, description string) (*Block, error) { 132 + body := map[string]string{ 133 + "label": label, 134 + "value": value, 135 + "description": description, 136 + } 137 + 138 + respBody, err := c.doRequest("POST", "/v1/blocks", body) 139 + if err != nil { 140 + return nil, err 141 + } 142 + 143 + var block Block 144 + if err := json.Unmarshal(respBody, &block); err != nil { 145 + return nil, fmt.Errorf("failed to parse block: %w", err) 146 + } 147 + 148 + return &block, nil 149 + } 150 + 151 + // UpdateBlock updates an existing block 152 + func (c *Client) UpdateBlock(blockID, value string) (*Block, error) { 153 + body := map[string]string{ 154 + "value": value, 155 + } 156 + 157 + respBody, err := c.doRequest("PATCH", "/v1/blocks/"+blockID, body) 158 + if err != nil { 159 + return nil, err 160 + } 161 + 162 + var block Block 163 + if err := json.Unmarshal(respBody, &block); err != nil { 164 + return nil, fmt.Errorf("failed to parse block: %w", err) 165 + } 166 + 167 + return &block, nil 168 + } 169 + 170 + // DeleteBlock deletes a block 171 + func (c *Client) DeleteBlock(blockID string) error { 172 + _, err := c.doRequest("DELETE", "/v1/blocks/"+blockID, nil) 173 + return err 174 + } 175 + 176 + // Agent represents a Letta agent 177 + type Agent struct { 178 + ID string `json:"id"` 179 + Name string `json:"name"` 180 + Description string `json:"description,omitempty"` 181 + CreatedAt string `json:"created_at,omitempty"` 182 + } 183 + 184 + // ListAgents lists all agents, optionally filtered by name search 185 + func (c *Client) ListAgents(nameSearch string) ([]Agent, error) { 186 + path := "/v1/agents/" 187 + params := url.Values{} 188 + if nameSearch != "" { 189 + params.Set("query_text", nameSearch) 190 + } 191 + if len(params) > 0 { 192 + path += "?" + params.Encode() 193 + } 194 + 195 + respBody, err := c.doRequest("GET", path, nil) 196 + if err != nil { 197 + return nil, err 198 + } 199 + 200 + var agents []Agent 201 + if err := json.Unmarshal(respBody, &agents); err != nil { 202 + return nil, fmt.Errorf("failed to parse agents: %w", err) 203 + } 204 + 205 + return agents, nil 206 + }
+131
internal/sync/pull.go
··· 1 + package sync 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "regexp" 8 + "strings" 9 + "time" 10 + 11 + "github.com/cpfiffer/note/internal/api" 12 + "github.com/cpfiffer/note/internal/util" 13 + ) 14 + 15 + // PullOptions configures the pull operation 16 + type PullOptions struct { 17 + Force bool 18 + Dir string 19 + } 20 + 21 + // PullResult contains the results of a pull operation 22 + type PullResult struct { 23 + Created []string 24 + Updated []string 25 + Conflicts []string 26 + Unchanged []string 27 + Skipped []string 28 + } 29 + 30 + // Pull downloads notes from Letta to local files 31 + func Pull(client *api.Client, state *State, opts PullOptions) (*PullResult, error) { 32 + result := &PullResult{} 33 + 34 + // Pattern to filter out legacy UUID paths and note_directory 35 + uuidPattern := regexp.MustCompile(`/\[?agent-[a-f0-9-]+\]?/`) 36 + 37 + // Fetch all blocks for this agent 38 + ownerSearch := fmt.Sprintf("owner:%s", state.AgentID) 39 + blocks, err := client.ListBlocks(ownerSearch) 40 + if err != nil { 41 + return nil, fmt.Errorf("failed to list blocks: %w", err) 42 + } 43 + 44 + for _, block := range blocks { 45 + // Skip non-path labels 46 + if !strings.HasPrefix(block.Label, "/") { 47 + continue 48 + } 49 + 50 + // Skip note_directory (auto-generated) 51 + if block.Label == "/note_directory" { 52 + result.Skipped = append(result.Skipped, block.Label) 53 + continue 54 + } 55 + 56 + // Skip legacy UUID paths 57 + if uuidPattern.MatchString(block.Label) { 58 + result.Skipped = append(result.Skipped, block.Label) 59 + continue 60 + } 61 + 62 + localPath := filepath.Join(opts.Dir, PathToFile(block.Label)) 63 + remoteHash := util.HashContent(block.Value) 64 + 65 + // Check if file exists locally 66 + localContent, err := os.ReadFile(localPath) 67 + localExists := err == nil 68 + 69 + if localExists { 70 + localHash := util.HashContent(string(localContent)) 71 + fileState, hasState := state.Files[block.Label] 72 + 73 + if hasState && localHash != fileState.LocalHash && remoteHash != fileState.RemoteHash { 74 + // Both changed - conflict! 75 + if !opts.Force { 76 + // Write conflict file with remote content 77 + conflictPath := filepath.Join(opts.Dir, ConflictFileName(block.Label)) 78 + if err := writeFile(conflictPath, block.Value); err != nil { 79 + return nil, fmt.Errorf("failed to write conflict file: %w", err) 80 + } 81 + result.Conflicts = append(result.Conflicts, block.Label) 82 + continue 83 + } 84 + } 85 + 86 + if remoteHash == localHash { 87 + // No changes 88 + result.Unchanged = append(result.Unchanged, block.Label) 89 + // Update state in case it was missing 90 + state.Files[block.Label] = FileState{ 91 + LocalHash: localHash, 92 + RemoteHash: remoteHash, 93 + SyncedAt: time.Now(), 94 + BlockID: block.ID, 95 + } 96 + continue 97 + } 98 + 99 + // Remote changed (or force mode) 100 + if err := writeFile(localPath, block.Value); err != nil { 101 + return nil, fmt.Errorf("failed to write file %s: %w", localPath, err) 102 + } 103 + result.Updated = append(result.Updated, block.Label) 104 + } else { 105 + // New file 106 + if err := writeFile(localPath, block.Value); err != nil { 107 + return nil, fmt.Errorf("failed to write file %s: %w", localPath, err) 108 + } 109 + result.Created = append(result.Created, block.Label) 110 + } 111 + 112 + // Update state 113 + state.Files[block.Label] = FileState{ 114 + LocalHash: remoteHash, // Local now matches remote 115 + RemoteHash: remoteHash, 116 + SyncedAt: time.Now(), 117 + BlockID: block.ID, 118 + } 119 + } 120 + 121 + return result, nil 122 + } 123 + 124 + // writeFile writes content to a file, creating directories as needed 125 + func writeFile(path, content string) error { 126 + dir := filepath.Dir(path) 127 + if err := os.MkdirAll(dir, 0755); err != nil { 128 + return fmt.Errorf("failed to create directory: %w", err) 129 + } 130 + return os.WriteFile(path, []byte(content), 0644) 131 + }
+163
internal/sync/push.go
··· 1 + package sync 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "time" 9 + 10 + "github.com/cpfiffer/note/internal/api" 11 + "github.com/cpfiffer/note/internal/util" 12 + ) 13 + 14 + // PushOptions configures the push operation 15 + type PushOptions struct { 16 + Force bool 17 + Dir string 18 + } 19 + 20 + // PushResult contains the results of a push operation 21 + type PushResult struct { 22 + Created []string 23 + Updated []string 24 + Conflicts []string 25 + Unchanged []string 26 + Skipped []string 27 + } 28 + 29 + // Push uploads local files to Letta 30 + func Push(client *api.Client, state *State, opts PushOptions) (*PushResult, error) { 31 + result := &PushResult{} 32 + 33 + // Walk the directory to find all .md files 34 + err := filepath.Walk(opts.Dir, func(path string, info os.FileInfo, err error) error { 35 + if err != nil { 36 + return err 37 + } 38 + 39 + // Skip directories 40 + if info.IsDir() { 41 + // Skip hidden directories 42 + if strings.HasPrefix(info.Name(), ".") && path != opts.Dir { 43 + return filepath.SkipDir 44 + } 45 + return nil 46 + } 47 + 48 + // Skip non-md files 49 + if !strings.HasSuffix(info.Name(), ".md") { 50 + return nil 51 + } 52 + 53 + // Skip hidden files and conflict files 54 + if strings.HasPrefix(info.Name(), ".") { 55 + return nil 56 + } 57 + if IsConflictFile(info.Name()) { 58 + result.Skipped = append(result.Skipped, path) 59 + return nil 60 + } 61 + 62 + // Get relative path from sync directory 63 + relPath, err := filepath.Rel(opts.Dir, path) 64 + if err != nil { 65 + return fmt.Errorf("failed to get relative path: %w", err) 66 + } 67 + 68 + notePath := FileToPath(relPath) 69 + 70 + // Skip note_directory 71 + if notePath == "/note_directory" { 72 + result.Skipped = append(result.Skipped, notePath) 73 + return nil 74 + } 75 + 76 + // Read local content 77 + content, err := os.ReadFile(path) 78 + if err != nil { 79 + return fmt.Errorf("failed to read file %s: %w", path, err) 80 + } 81 + localContent := string(content) 82 + localHash := util.HashContent(localContent) 83 + 84 + // Check state 85 + fileState, hasState := state.Files[notePath] 86 + 87 + // Get remote block 88 + ownerSearch := fmt.Sprintf("owner:%s", state.AgentID) 89 + blocks, err := client.ListBlocks(ownerSearch) 90 + if err != nil { 91 + return fmt.Errorf("failed to list blocks: %w", err) 92 + } 93 + 94 + var remoteBlock *api.Block 95 + for i := range blocks { 96 + if blocks[i].Label == notePath { 97 + remoteBlock = &blocks[i] 98 + break 99 + } 100 + } 101 + 102 + if remoteBlock != nil { 103 + remoteHash := util.HashContent(remoteBlock.Value) 104 + 105 + // Check for conflicts 106 + if hasState && remoteHash != fileState.RemoteHash && localHash != fileState.LocalHash { 107 + if !opts.Force { 108 + result.Conflicts = append(result.Conflicts, notePath) 109 + return nil 110 + } 111 + } 112 + 113 + // Check if unchanged 114 + if localHash == remoteHash { 115 + result.Unchanged = append(result.Unchanged, notePath) 116 + state.Files[notePath] = FileState{ 117 + LocalHash: localHash, 118 + RemoteHash: remoteHash, 119 + SyncedAt: time.Now(), 120 + BlockID: remoteBlock.ID, 121 + } 122 + return nil 123 + } 124 + 125 + // Update remote 126 + _, err := client.UpdateBlock(remoteBlock.ID, localContent) 127 + if err != nil { 128 + return fmt.Errorf("failed to update block %s: %w", notePath, err) 129 + } 130 + result.Updated = append(result.Updated, notePath) 131 + 132 + state.Files[notePath] = FileState{ 133 + LocalHash: localHash, 134 + RemoteHash: localHash, // Remote now matches local 135 + SyncedAt: time.Now(), 136 + BlockID: remoteBlock.ID, 137 + } 138 + } else { 139 + // Create new block 140 + description := fmt.Sprintf("owner:%s", state.AgentID) 141 + block, err := client.CreateBlock(notePath, localContent, description) 142 + if err != nil { 143 + return fmt.Errorf("failed to create block %s: %w", notePath, err) 144 + } 145 + result.Created = append(result.Created, notePath) 146 + 147 + state.Files[notePath] = FileState{ 148 + LocalHash: localHash, 149 + RemoteHash: localHash, 150 + SyncedAt: time.Now(), 151 + BlockID: block.ID, 152 + } 153 + } 154 + 155 + return nil 156 + }) 157 + 158 + if err != nil { 159 + return nil, err 160 + } 161 + 162 + return result, nil 163 + }
+124
internal/sync/state.go
··· 1 + package sync 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "time" 9 + ) 10 + 11 + const StateFileName = ".note-sync.json" 12 + 13 + // FileState tracks the sync state of a single file 14 + type FileState struct { 15 + LocalHash string `json:"local_hash"` 16 + RemoteHash string `json:"remote_hash"` 17 + SyncedAt time.Time `json:"synced_at"` 18 + BlockID string `json:"block_id,omitempty"` 19 + } 20 + 21 + // State represents the overall sync state 22 + type State struct { 23 + AgentID string `json:"agent_id"` 24 + LettaBaseURL string `json:"letta_base_url"` 25 + Files map[string]FileState `json:"files"` 26 + } 27 + 28 + // LoadState loads the sync state from the current directory 29 + func LoadState(dir string) (*State, error) { 30 + statePath := filepath.Join(dir, StateFileName) 31 + 32 + data, err := os.ReadFile(statePath) 33 + if err != nil { 34 + if os.IsNotExist(err) { 35 + return nil, fmt.Errorf("not a note-sync directory (no %s found). Run 'note-sync init' first", StateFileName) 36 + } 37 + return nil, fmt.Errorf("failed to read state file: %w", err) 38 + } 39 + 40 + var state State 41 + if err := json.Unmarshal(data, &state); err != nil { 42 + return nil, fmt.Errorf("failed to parse state file: %w", err) 43 + } 44 + 45 + if state.Files == nil { 46 + state.Files = make(map[string]FileState) 47 + } 48 + 49 + return &state, nil 50 + } 51 + 52 + // SaveState saves the sync state to disk 53 + func (s *State) SaveState(dir string) error { 54 + statePath := filepath.Join(dir, StateFileName) 55 + 56 + data, err := json.MarshalIndent(s, "", " ") 57 + if err != nil { 58 + return fmt.Errorf("failed to marshal state: %w", err) 59 + } 60 + 61 + if err := os.WriteFile(statePath, data, 0644); err != nil { 62 + return fmt.Errorf("failed to write state file: %w", err) 63 + } 64 + 65 + return nil 66 + } 67 + 68 + // InitState creates a new state file 69 + func InitState(dir, agentID, baseURL string) (*State, error) { 70 + if baseURL == "" { 71 + baseURL = "https://api.letta.com" 72 + } 73 + 74 + // Create directory if it doesn't exist 75 + if err := os.MkdirAll(dir, 0755); err != nil { 76 + return nil, fmt.Errorf("failed to create directory: %w", err) 77 + } 78 + 79 + state := &State{ 80 + AgentID: agentID, 81 + LettaBaseURL: baseURL, 82 + Files: make(map[string]FileState), 83 + } 84 + 85 + if err := state.SaveState(dir); err != nil { 86 + return nil, err 87 + } 88 + 89 + return state, nil 90 + } 91 + 92 + // PathToFile converts a Letta note path to a local file path 93 + // /projects/webapp -> projects/webapp.md 94 + func PathToFile(notePath string) string { 95 + // Remove leading slash 96 + if len(notePath) > 0 && notePath[0] == '/' { 97 + notePath = notePath[1:] 98 + } 99 + return notePath + ".md" 100 + } 101 + 102 + // FileToPath converts a local file path to a Letta note path 103 + // projects/webapp.md -> /projects/webapp 104 + func FileToPath(filePath string) string { 105 + // Remove .md extension 106 + if len(filePath) > 3 && filePath[len(filePath)-3:] == ".md" { 107 + filePath = filePath[:len(filePath)-3] 108 + } 109 + return "/" + filePath 110 + } 111 + 112 + // IsConflictFile checks if a file is a conflict file 113 + func IsConflictFile(filename string) bool { 114 + return len(filename) > 12 && filename[len(filename)-12:] == ".conflict.md" 115 + } 116 + 117 + // ConflictFileName returns the conflict file name for a path 118 + func ConflictFileName(notePath string) string { 119 + // /projects/webapp -> projects/webapp.conflict.md 120 + if len(notePath) > 0 && notePath[0] == '/' { 121 + notePath = notePath[1:] 122 + } 123 + return notePath + ".conflict.md" 124 + }
+141
internal/sync/status.go
··· 1 + package sync 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "regexp" 8 + "strings" 9 + 10 + "github.com/cpfiffer/note/internal/api" 11 + "github.com/cpfiffer/note/internal/util" 12 + ) 13 + 14 + // StatusResult contains the sync status 15 + type StatusResult struct { 16 + ModifiedLocally []string 17 + ModifiedRemotely []string 18 + Conflicts []string 19 + UntrackedLocal []string 20 + UntrackedRemote []string 21 + Synced []string 22 + } 23 + 24 + // Status computes the sync status 25 + func Status(client *api.Client, state *State, dir string) (*StatusResult, error) { 26 + result := &StatusResult{} 27 + 28 + // Pattern to filter out legacy UUID paths 29 + uuidPattern := regexp.MustCompile(`/\[?agent-[a-f0-9-]+\]?/`) 30 + 31 + // Get remote blocks 32 + ownerSearch := fmt.Sprintf("owner:%s", state.AgentID) 33 + blocks, err := client.ListBlocks(ownerSearch) 34 + if err != nil { 35 + return nil, fmt.Errorf("failed to list blocks: %w", err) 36 + } 37 + 38 + // Build map of remote blocks 39 + remoteBlocks := make(map[string]*api.Block) 40 + for i := range blocks { 41 + block := &blocks[i] 42 + if !strings.HasPrefix(block.Label, "/") { 43 + continue 44 + } 45 + if block.Label == "/note_directory" { 46 + continue 47 + } 48 + if uuidPattern.MatchString(block.Label) { 49 + continue 50 + } 51 + remoteBlocks[block.Label] = block 52 + } 53 + 54 + // Track which paths we've seen 55 + seenPaths := make(map[string]bool) 56 + 57 + // Check local files 58 + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 59 + if err != nil { 60 + return err 61 + } 62 + 63 + if info.IsDir() { 64 + if strings.HasPrefix(info.Name(), ".") && path != dir { 65 + return filepath.SkipDir 66 + } 67 + return nil 68 + } 69 + 70 + if !strings.HasSuffix(info.Name(), ".md") { 71 + return nil 72 + } 73 + if strings.HasPrefix(info.Name(), ".") || IsConflictFile(info.Name()) { 74 + return nil 75 + } 76 + 77 + relPath, _ := filepath.Rel(dir, path) 78 + notePath := FileToPath(relPath) 79 + 80 + if notePath == "/note_directory" { 81 + return nil 82 + } 83 + 84 + seenPaths[notePath] = true 85 + 86 + content, err := os.ReadFile(path) 87 + if err != nil { 88 + return nil 89 + } 90 + localHash := util.HashContent(string(content)) 91 + 92 + fileState, hasState := state.Files[notePath] 93 + remoteBlock, hasRemote := remoteBlocks[notePath] 94 + 95 + if !hasRemote { 96 + // Only exists locally 97 + result.UntrackedLocal = append(result.UntrackedLocal, notePath) 98 + return nil 99 + } 100 + 101 + remoteHash := util.HashContent(remoteBlock.Value) 102 + 103 + if !hasState { 104 + // Not tracked but exists both places 105 + if localHash != remoteHash { 106 + result.Conflicts = append(result.Conflicts, notePath) 107 + } else { 108 + result.Synced = append(result.Synced, notePath) 109 + } 110 + return nil 111 + } 112 + 113 + localChanged := localHash != fileState.LocalHash 114 + remoteChanged := remoteHash != fileState.RemoteHash 115 + 116 + if localChanged && remoteChanged { 117 + result.Conflicts = append(result.Conflicts, notePath) 118 + } else if localChanged { 119 + result.ModifiedLocally = append(result.ModifiedLocally, notePath) 120 + } else if remoteChanged { 121 + result.ModifiedRemotely = append(result.ModifiedRemotely, notePath) 122 + } else { 123 + result.Synced = append(result.Synced, notePath) 124 + } 125 + 126 + return nil 127 + }) 128 + 129 + if err != nil { 130 + return nil, err 131 + } 132 + 133 + // Check for remote-only files 134 + for path := range remoteBlocks { 135 + if !seenPaths[path] { 136 + result.UntrackedRemote = append(result.UntrackedRemote, path) 137 + } 138 + } 139 + 140 + return result, nil 141 + }
+12
internal/util/hash.go
··· 1 + package util 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + ) 7 + 8 + // HashContent returns a truncated SHA256 hash of the content 9 + func HashContent(content string) string { 10 + h := sha256.Sum256([]byte(content)) 11 + return hex.EncodeToString(h[:])[:16] 12 + }
+230
internal/web/server.go
··· 1 + package web 2 + 3 + import ( 4 + "embed" 5 + "encoding/json" 6 + "fmt" 7 + "html/template" 8 + "net/http" 9 + "strings" 10 + 11 + "github.com/cpfiffer/note/internal/api" 12 + ) 13 + 14 + //go:embed templates/*.html 15 + var templateFS embed.FS 16 + 17 + //go:embed static/* 18 + var staticFS embed.FS 19 + 20 + type Server struct { 21 + client *api.Client 22 + templates *template.Template 23 + mux *http.ServeMux 24 + } 25 + 26 + func NewServer() (*Server, error) { 27 + client, err := api.NewClient("") 28 + if err != nil { 29 + return nil, err 30 + } 31 + 32 + tmpl, err := template.ParseFS(templateFS, "templates/*.html") 33 + if err != nil { 34 + return nil, fmt.Errorf("failed to parse templates: %w", err) 35 + } 36 + 37 + s := &Server{ 38 + client: client, 39 + templates: tmpl, 40 + mux: http.NewServeMux(), 41 + } 42 + 43 + s.setupRoutes() 44 + return s, nil 45 + } 46 + 47 + func (s *Server) setupRoutes() { 48 + // Static files 49 + s.mux.Handle("/static/", http.FileServer(http.FS(staticFS))) 50 + 51 + // Pages 52 + s.mux.HandleFunc("/", s.handleIndex) 53 + s.mux.HandleFunc("/agents", s.handleAgents) 54 + s.mux.HandleFunc("/notes", s.handleNotes) 55 + 56 + // API endpoints for htmx 57 + s.mux.HandleFunc("/api/agents", s.handleAPIAgents) 58 + s.mux.HandleFunc("/api/notes", s.handleAPINotes) 59 + s.mux.HandleFunc("/api/note/", s.handleAPINote) 60 + } 61 + 62 + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 63 + s.mux.ServeHTTP(w, r) 64 + } 65 + 66 + func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { 67 + s.templates.ExecuteTemplate(w, "index.html", nil) 68 + } 69 + 70 + func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) { 71 + search := r.URL.Query().Get("search") 72 + agents, err := s.client.ListAgents(search) 73 + if err != nil { 74 + http.Error(w, err.Error(), http.StatusInternalServerError) 75 + return 76 + } 77 + 78 + s.templates.ExecuteTemplate(w, "agents.html", map[string]interface{}{ 79 + "Agents": agents, 80 + "Search": search, 81 + }) 82 + } 83 + 84 + func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) { 85 + agentID := r.URL.Query().Get("agent") 86 + if agentID == "" { 87 + http.Redirect(w, r, "/agents", http.StatusFound) 88 + return 89 + } 90 + 91 + ownerSearch := fmt.Sprintf("owner:%s", agentID) 92 + blocks, err := s.client.ListBlocks(ownerSearch) 93 + if err != nil { 94 + http.Error(w, err.Error(), http.StatusInternalServerError) 95 + return 96 + } 97 + 98 + // Filter to note paths only 99 + var notes []api.Block 100 + for _, b := range blocks { 101 + if strings.HasPrefix(b.Label, "/") && b.Label != "/note_directory" { 102 + notes = append(notes, b) 103 + } 104 + } 105 + 106 + s.templates.ExecuteTemplate(w, "notes.html", map[string]interface{}{ 107 + "AgentID": agentID, 108 + "Notes": notes, 109 + }) 110 + } 111 + 112 + func (s *Server) handleAPIAgents(w http.ResponseWriter, r *http.Request) { 113 + search := r.URL.Query().Get("search") 114 + agents, err := s.client.ListAgents(search) 115 + if err != nil { 116 + http.Error(w, err.Error(), http.StatusInternalServerError) 117 + return 118 + } 119 + 120 + w.Header().Set("Content-Type", "text/html") 121 + s.templates.ExecuteTemplate(w, "agent-list.html", agents) 122 + } 123 + 124 + func (s *Server) handleAPINotes(w http.ResponseWriter, r *http.Request) { 125 + agentID := r.URL.Query().Get("agent") 126 + if agentID == "" { 127 + http.Error(w, "agent required", http.StatusBadRequest) 128 + return 129 + } 130 + 131 + ownerSearch := fmt.Sprintf("owner:%s", agentID) 132 + blocks, err := s.client.ListBlocks(ownerSearch) 133 + if err != nil { 134 + http.Error(w, err.Error(), http.StatusInternalServerError) 135 + return 136 + } 137 + 138 + var notes []api.Block 139 + for _, b := range blocks { 140 + if strings.HasPrefix(b.Label, "/") && b.Label != "/note_directory" { 141 + notes = append(notes, b) 142 + } 143 + } 144 + 145 + w.Header().Set("Content-Type", "text/html") 146 + s.templates.ExecuteTemplate(w, "note-list.html", notes) 147 + } 148 + 149 + func (s *Server) handleAPINote(w http.ResponseWriter, r *http.Request) { 150 + // Extract path from URL: /api/note/{agent}/{path...} 151 + path := strings.TrimPrefix(r.URL.Path, "/api/note/") 152 + parts := strings.SplitN(path, "/", 2) 153 + if len(parts) < 2 { 154 + http.Error(w, "invalid path", http.StatusBadRequest) 155 + return 156 + } 157 + agentID := parts[0] 158 + notePath := "/" + parts[1] 159 + 160 + ownerSearch := fmt.Sprintf("owner:%s", agentID) 161 + 162 + switch r.Method { 163 + case "GET": 164 + blocks, err := s.client.ListBlocks(ownerSearch) 165 + if err != nil { 166 + http.Error(w, err.Error(), http.StatusInternalServerError) 167 + return 168 + } 169 + 170 + var note *api.Block 171 + for _, b := range blocks { 172 + if b.Label == notePath { 173 + note = &b 174 + break 175 + } 176 + } 177 + 178 + if note == nil { 179 + http.Error(w, "note not found", http.StatusNotFound) 180 + return 181 + } 182 + 183 + w.Header().Set("Content-Type", "application/json") 184 + json.NewEncoder(w).Encode(note) 185 + 186 + case "PUT": 187 + var body struct { 188 + Value string `json:"value"` 189 + } 190 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 191 + http.Error(w, err.Error(), http.StatusBadRequest) 192 + return 193 + } 194 + 195 + blocks, err := s.client.ListBlocks(ownerSearch) 196 + if err != nil { 197 + http.Error(w, err.Error(), http.StatusInternalServerError) 198 + return 199 + } 200 + 201 + var blockID string 202 + for _, b := range blocks { 203 + if b.Label == notePath { 204 + blockID = b.ID 205 + break 206 + } 207 + } 208 + 209 + if blockID == "" { 210 + http.Error(w, "note not found", http.StatusNotFound) 211 + return 212 + } 213 + 214 + _, err = s.client.UpdateBlock(blockID, body.Value) 215 + if err != nil { 216 + http.Error(w, err.Error(), http.StatusInternalServerError) 217 + return 218 + } 219 + 220 + w.WriteHeader(http.StatusOK) 221 + 222 + default: 223 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 224 + } 225 + } 226 + 227 + func (s *Server) Start(addr string) error { 228 + fmt.Printf("Starting server at http://%s\n", addr) 229 + return http.ListenAndServe(addr, s) 230 + }
internal/web/static/.gitkeep

This is a binary file and will not be displayed.

+9
internal/web/templates/agent-list.html
··· 1 + {{range .}} 2 + <article class="agent-card" onclick="window.location='/notes?agent={{.ID}}'"> 3 + <strong>{{.Name}}</strong> 4 + {{if .Description}}<p>{{.Description}}</p>{{end}} 5 + <small class="agent-id">{{.ID}}</small> 6 + </article> 7 + {{else}} 8 + <p>No agents found.</p> 9 + {{end}}
+51
internal/web/templates/agents.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en" data-theme="light"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Agents - note-sync</title> 7 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> 8 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 9 + <style> 10 + :root { --pico-font-size: 16px; } 11 + .container { max-width: 900px; padding-top: 2rem; } 12 + .agent-card { 13 + cursor: pointer; 14 + margin-bottom: 0.5rem; 15 + } 16 + .agent-card:hover { 17 + border-color: var(--pico-primary); 18 + } 19 + .agent-id { 20 + font-family: monospace; 21 + font-size: 0.8rem; 22 + color: var(--pico-muted-color); 23 + } 24 + .search-box { 25 + margin-bottom: 1.5rem; 26 + } 27 + </style> 28 + </head> 29 + <body> 30 + <main class="container"> 31 + <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> 32 + <h1>Select Agent</h1> 33 + <a href="/">← Home</a> 34 + </div> 35 + 36 + <div class="search-box"> 37 + <input type="search" 38 + name="search" 39 + placeholder="Search agents..." 40 + value="{{.Search}}" 41 + hx-get="/api/agents" 42 + hx-trigger="input changed delay:300ms, search" 43 + hx-target="#agent-list"> 44 + </div> 45 + 46 + <div id="agent-list"> 47 + {{template "agent-list.html" .Agents}} 48 + </div> 49 + </main> 50 + </body> 51 + </html>
+83
internal/web/templates/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en" data-theme="light"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>note-sync</title> 7 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> 8 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 9 + <style> 10 + :root { 11 + --pico-font-size: 16px; 12 + } 13 + .container { 14 + max-width: 900px; 15 + padding-top: 2rem; 16 + } 17 + .note-tree { 18 + font-family: monospace; 19 + } 20 + .note-item { 21 + padding: 0.5rem; 22 + cursor: pointer; 23 + border-radius: 4px; 24 + } 25 + .note-item:hover { 26 + background: var(--pico-secondary-hover-background); 27 + } 28 + .note-content { 29 + font-family: monospace; 30 + white-space: pre-wrap; 31 + background: var(--pico-code-background-color); 32 + padding: 1rem; 33 + border-radius: 4px; 34 + min-height: 300px; 35 + } 36 + textarea.note-editor { 37 + font-family: monospace; 38 + min-height: 400px; 39 + } 40 + .header-nav { 41 + display: flex; 42 + justify-content: space-between; 43 + align-items: center; 44 + margin-bottom: 2rem; 45 + } 46 + .agent-card { 47 + cursor: pointer; 48 + } 49 + .agent-card:hover { 50 + border-color: var(--pico-primary); 51 + } 52 + #toast { 53 + position: fixed; 54 + bottom: 1rem; 55 + right: 1rem; 56 + padding: 1rem; 57 + background: var(--pico-primary); 58 + color: white; 59 + border-radius: 4px; 60 + display: none; 61 + } 62 + #toast.show { 63 + display: block; 64 + } 65 + </style> 66 + </head> 67 + <body> 68 + <main class="container"> 69 + <div class="header-nav"> 70 + <h1>note-sync</h1> 71 + <nav> 72 + <a href="/agents">Agents</a> 73 + </nav> 74 + </div> 75 + 76 + <article> 77 + <h2>Welcome to note-sync</h2> 78 + <p>A bidirectional sync tool for Letta notes.</p> 79 + <p><a href="/agents" role="button">Select an Agent →</a></p> 80 + </article> 81 + </main> 82 + </body> 83 + </html>
+7
internal/web/templates/note-list.html
··· 1 + {{range .}} 2 + <div class="note-item" onclick="loadNote(currentAgentID, '{{.Label}}', this)"> 3 + {{.Label}} 4 + </div> 5 + {{else}} 6 + <p class="empty-state">No notes yet.</p> 7 + {{end}}
+140
internal/web/templates/notes.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Notes</title> 7 + <style> 8 + * { box-sizing: border-box; } 9 + body { font-family: system-ui, sans-serif; margin: 0; padding: 20px; } 10 + .layout { display: flex; gap: 20px; max-width: 1200px; margin: 0 auto; } 11 + .sidebar { width: 250px; border-right: 1px solid #ccc; padding-right: 20px; } 12 + .main { flex: 1; } 13 + .note-item { padding: 8px; cursor: pointer; font-family: monospace; font-size: 14px; } 14 + .note-item:hover { background: #eee; } 15 + .note-item.active { background: #007bff; color: white; } 16 + .toolbar { margin-bottom: 10px; } 17 + .toolbar button { margin-right: 5px; padding: 5px 10px; } 18 + #editor { border: 1px solid #ccc; min-height: 400px; } 19 + #editor .ProseMirror { padding: 15px; min-height: 400px; outline: none; } 20 + .actions { margin-top: 10px; } 21 + .actions button { padding: 8px 16px; margin-right: 10px; } 22 + #toast { position: fixed; bottom: 20px; right: 20px; background: #333; color: white; padding: 10px 20px; border-radius: 4px; display: none; } 23 + #toast.show { display: block; } 24 + a { color: #007bff; } 25 + h1 { margin-top: 0; } 26 + </style> 27 + </head> 28 + <body> 29 + <div class="layout"> 30 + <div class="sidebar"> 31 + <h3>Files</h3> 32 + {{range .Notes}} 33 + <div class="note-item" data-path="{{.Label}}">{{.Label}}</div> 34 + {{else}} 35 + <p>No notes yet.</p> 36 + {{end}} 37 + <p style="margin-top:20px"><a href="/agents">← Change Agent</a></p> 38 + </div> 39 + 40 + <div class="main"> 41 + <div id="editor-area"> 42 + <p>Select a note from the sidebar</p> 43 + </div> 44 + </div> 45 + </div> 46 + 47 + <div id="toast"></div> 48 + <script id="page-data" data-agent-id="{{.AgentID}}"></script> 49 + 50 + <script type="module"> 51 + import { Editor } from 'https://esm.sh/@tiptap/core@2.1.13' 52 + import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.1.13' 53 + import { marked } from 'https://esm.sh/marked@11.1.0' 54 + import TurndownService from 'https://esm.sh/turndown@7.1.2' 55 + 56 + const agentID = document.getElementById('page-data').dataset.agentId; 57 + let currentPath = ''; 58 + let editor = null; 59 + const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); 60 + 61 + document.querySelectorAll('.note-item').forEach(item => { 62 + item.addEventListener('click', () => { 63 + document.querySelectorAll('.note-item').forEach(i => i.classList.remove('active')); 64 + item.classList.add('active'); 65 + loadNote(item.dataset.path); 66 + }); 67 + }); 68 + 69 + async function loadNote(path) { 70 + currentPath = path; 71 + const resp = await fetch('/api/note/' + agentID + path); 72 + const note = await resp.json(); 73 + showEditor(note.label, note.value || ''); 74 + } 75 + 76 + function showEditor(path, markdown) { 77 + const html = marked.parse(markdown); 78 + 79 + document.getElementById('editor-area').innerHTML = 80 + '<h2>' + path + '</h2>' + 81 + '<div class="toolbar">' + 82 + '<button data-cmd="bold"><b>B</b></button>' + 83 + '<button data-cmd="italic"><i>I</i></button>' + 84 + '<button data-cmd="h1">H1</button>' + 85 + '<button data-cmd="h2">H2</button>' + 86 + '<button data-cmd="bullet">• List</button>' + 87 + '<button data-cmd="codeblock">Code</button>' + 88 + '</div>' + 89 + '<div id="editor"></div>' + 90 + '<div class="actions">' + 91 + '<button id="save-btn">Save</button>' + 92 + '<button id="copy-btn">Copy Markdown</button>' + 93 + '</div>'; 94 + 95 + if (editor) editor.destroy(); 96 + 97 + editor = new Editor({ 98 + element: document.getElementById('editor'), 99 + extensions: [StarterKit], 100 + content: html, 101 + }); 102 + 103 + document.querySelectorAll('[data-cmd]').forEach(btn => { 104 + btn.addEventListener('click', () => { 105 + const cmd = btn.dataset.cmd; 106 + const chain = editor.chain().focus(); 107 + if (cmd === 'bold') chain.toggleBold().run(); 108 + if (cmd === 'italic') chain.toggleItalic().run(); 109 + if (cmd === 'h1') chain.toggleHeading({level:1}).run(); 110 + if (cmd === 'h2') chain.toggleHeading({level:2}).run(); 111 + if (cmd === 'bullet') chain.toggleBulletList().run(); 112 + if (cmd === 'codeblock') chain.toggleCodeBlock().run(); 113 + }); 114 + }); 115 + 116 + document.getElementById('save-btn').addEventListener('click', async () => { 117 + const md = turndown.turndown(editor.getHTML()); 118 + const resp = await fetch('/api/note/' + agentID + currentPath, { 119 + method: 'PUT', 120 + headers: {'Content-Type': 'application/json'}, 121 + body: JSON.stringify({value: md}) 122 + }); 123 + showToast(resp.ok ? 'Saved!' : 'Error'); 124 + }); 125 + 126 + document.getElementById('copy-btn').addEventListener('click', () => { 127 + navigator.clipboard.writeText(turndown.turndown(editor.getHTML())); 128 + showToast('Copied!'); 129 + }); 130 + } 131 + 132 + function showToast(msg) { 133 + const t = document.getElementById('toast'); 134 + t.textContent = msg; 135 + t.classList.add('show'); 136 + setTimeout(() => t.classList.remove('show'), 2000); 137 + } 138 + </script> 139 + </body> 140 + </html>