package api import ( "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "strings" "time" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" ) // ContentHash returns the lowercase hex sha256 of b. It is the note's // content_hash handle: brain_write / brain_update return it, brain_get // recomputes it from the file on disk, and brain_update stamps the prior // note's hash into the new note's `supersedes` frontmatter. func ContentHash(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } // resolveWithin maps a brainDir-relative path to an absolute path and // guarantees it does not escape brainDir. Returns the cleaned relPath // (forward-slashed) and the absolute path. func resolveWithin(brainDir, relPath string) (rel, abs string, err error) { clean := filepath.Clean("/" + filepath.ToSlash(relPath)) rel = strings.TrimPrefix(clean, "/") abs = filepath.Join(brainDir, filepath.FromSlash(rel)) check, err := filepath.Rel(brainDir, abs) if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) { return "", "", fmt.Errorf("path %q escapes brain dir", relPath) } return rel, abs, nil } // UpdateNoteOptions identifies the note to supersede and supplies its new // body. Path takes precedence; otherwise the target is resolved from // Wing/Hall/Slug via brain.NotePath. type UpdateNoteOptions struct { Path string // brainDir-relative path; takes precedence over wing/hall/slug Wing string Hall string Slug string Content string // new full body (whole-note replace) Reason string // optional; stamped as supersede_reason } // UpdateNote supersedes an existing note in place. It replaces the body // with opts.Content, preserves the existing frontmatter (created_at, // wing, hall, and any custom fields), and stamps updated_at, supersedes // (the prior content hash), and supersede_reason (when given). // // It never creates: if the target does not exist, it returns an error so // the caller can fall back to brain_write. Returns the note's relPath, // the new content hash, and the prior content hash. // // Embeddings are NOT refreshed here. The rewritten file's mtime advances, // which the mtime-driven vectorstore.Sync ticker uses to re-embed it on // its next pass — the same out-of-band mechanism brain_write relies on. func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, priorHash string, err error) { if opts.Content == "" { return "", "", "", fmt.Errorf("content is required") } var rel string if opts.Path != "" { rel = opts.Path } else { full, perr := brain.NotePath(brainDir, opts.Wing, opts.Hall, opts.Slug) if perr != nil { return "", "", "", perr } rel, _ = filepath.Rel(brainDir, full) rel = filepath.ToSlash(rel) } rel, abs, err := resolveWithin(brainDir, rel) if err != nil { return "", "", "", err } prior, err := os.ReadFile(abs) if err != nil { if os.IsNotExist(err) { return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel) } return "", "", "", fmt.Errorf("read target: %w", err) } priorHash = ContentHash(prior) fm, _ := parseFrontmatter(string(prior)) fm.set("updated_at", time.Now().UTC().Format(time.RFC3339)) fm.set("supersedes", priorHash) if opts.Reason != "" { fm.set("supersede_reason", opts.Reason) } out := []byte(fm.render() + opts.Content) if err := os.WriteFile(abs, out, 0o644); err != nil { return "", "", "", fmt.Errorf("write: %w", err) } return rel, ContentHash(out), priorHash, nil } // ReadNote reads the note at the brainDir-relative relPath and returns // its parsed frontmatter, body, and content hash. It is the read-after- // write primitive behind brain_get: the hash it returns equals the hash // brain_write / brain_update returned for the same bytes. func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) { _, abs, err := resolveWithin(brainDir, relPath) if err != nil { return nil, "", "", err } raw, err := os.ReadFile(abs) if err != nil { if os.IsNotExist(err) { return nil, "", "", fmt.Errorf("note %q does not exist", relPath) } return nil, "", "", fmt.Errorf("read note: %w", err) } parsed, body := parseFrontmatter(string(raw)) fm = make(map[string]string, len(parsed.lines)) for _, l := range parsed.lines { if l.key != "" { fm[l.key] = l.value } } return fm, body, ContentHash(raw), nil }