Implements the api-layer half of #45. UpdateNote supersedes a note in place (whole-note body replace, frontmatter re-stamp: updated_at, supersedes=prior content hash, supersede_reason), preserving created_at, wing, hall, and any custom fields. Never creates — a missing target is an error so callers fall back to brain_write. ReadNote is the read-after- write primitive (frontmatter + body + content_hash). ContentHash is the sha256 handle that round-trips write/update → get. Frontmatter is edited via a line-preserving ordered editor rather than a yaml.v3 round-trip, which would reorder keys and strip comments — the brain writes flat key:value frontmatter by hand. Embeddings are not refreshed here: the rewritten file's mtime advances, which the out-of-band vectorstore.Sync ticker uses to re-embed it — the same mechanism brain_write relies on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
4.4 KiB
Go
132 lines
4.4 KiB
Go
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
|
|
}
|