feat(brain): add UpdateNote/ReadNote supersede primitives + frontmatter editor
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>
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// frontmatter is an ordered, line-preserving view of a note's YAML
|
||||||
|
// frontmatter block. It deliberately avoids a full YAML round-trip: the
|
||||||
|
// brain writes flat `key: value` frontmatter by hand, and a yaml.v3
|
||||||
|
// re-marshal would reorder keys and strip comments. Preserving the
|
||||||
|
// original lines verbatim keeps brain_update a surgical edit — only the
|
||||||
|
// keys it manages (updated_at, supersedes, supersede_reason) change.
|
||||||
|
type frontmatter struct {
|
||||||
|
lines []fmLine
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmLine is one frontmatter line. For `key: value` lines, key and value
|
||||||
|
// are populated; for blank lines, comments, or anything that isn't a
|
||||||
|
// simple scalar pair, key is empty and raw holds the line verbatim.
|
||||||
|
type fmLine struct {
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFrontmatter splits src into its frontmatter block and body. A
|
||||||
|
// frontmatter block is recognised only when the file opens with a `---`
|
||||||
|
// fence and a closing `---` fence follows. Otherwise the whole input is
|
||||||
|
// the body and the returned frontmatter is empty.
|
||||||
|
func parseFrontmatter(src string) (frontmatter, string) {
|
||||||
|
var fm frontmatter
|
||||||
|
if !strings.HasPrefix(src, "---\n") {
|
||||||
|
return fm, src
|
||||||
|
}
|
||||||
|
rest := src[len("---\n"):]
|
||||||
|
end := strings.Index(rest, "\n---\n")
|
||||||
|
if end < 0 {
|
||||||
|
// Opening fence with no closing fence — treat as bodyless content.
|
||||||
|
return fm, src
|
||||||
|
}
|
||||||
|
block := rest[:end]
|
||||||
|
body := rest[end+len("\n---\n"):]
|
||||||
|
|
||||||
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
|
fm.lines = append(fm.lines, fmLine{raw: line})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fm.lines = append(fm.lines, fmLine{key: key, value: strings.TrimSpace(val)})
|
||||||
|
}
|
||||||
|
return fm, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the value for key, or "" if absent.
|
||||||
|
func (f *frontmatter) get(key string) string {
|
||||||
|
for _, l := range f.lines {
|
||||||
|
if l.key == key {
|
||||||
|
return l.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// set overrides the value for an existing key in place, or appends a new
|
||||||
|
// `key: value` line when the key is absent.
|
||||||
|
func (f *frontmatter) set(key, value string) {
|
||||||
|
for i := range f.lines {
|
||||||
|
if f.lines[i].key == key {
|
||||||
|
f.lines[i].value = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.lines = append(f.lines, fmLine{key: key, value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
// render serialises the frontmatter back into a `---`-fenced block. An
|
||||||
|
// empty frontmatter renders to the empty string so bodies without a
|
||||||
|
// header stay header-less.
|
||||||
|
func (f *frontmatter) render() string {
|
||||||
|
if len(f.lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("---\n")
|
||||||
|
for _, l := range f.lines {
|
||||||
|
if l.key == "" {
|
||||||
|
b.WriteString(l.raw)
|
||||||
|
} else {
|
||||||
|
b.WriteString(l.key)
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(l.value)
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString("---\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFrontmatterSplitsHeaderAndBody(t *testing.T) {
|
||||||
|
src := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Title\n\nbody text\n"
|
||||||
|
fm, body := parseFrontmatter(src)
|
||||||
|
|
||||||
|
assert.Equal(t, "jepa-fx", fm.get("wing"))
|
||||||
|
assert.Equal(t, "facts", fm.get("hall"))
|
||||||
|
assert.Equal(t, "2026-01-01T00:00:00Z", fm.get("created_at"))
|
||||||
|
assert.Equal(t, "# Title\n\nbody text\n", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFrontmatterNoHeader(t *testing.T) {
|
||||||
|
src := "# Just a body\n\nno frontmatter here\n"
|
||||||
|
fm, body := parseFrontmatter(src)
|
||||||
|
|
||||||
|
assert.Empty(t, fm.lines)
|
||||||
|
assert.Equal(t, src, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterSetOverridesExistingKey(t *testing.T) {
|
||||||
|
fm, _ := parseFrontmatter("---\nwing: a\nupdated_at: old\n---\nbody\n")
|
||||||
|
fm.set("updated_at", "new")
|
||||||
|
|
||||||
|
assert.Equal(t, "new", fm.get("updated_at"))
|
||||||
|
// No duplicate key.
|
||||||
|
assert.Equal(t, 1, strings.Count(fm.render(), "updated_at:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterSetAppendsNewKey(t *testing.T) {
|
||||||
|
fm, _ := parseFrontmatter("---\nwing: a\n---\nbody\n")
|
||||||
|
fm.set("supersedes", "abc123")
|
||||||
|
|
||||||
|
out := fm.render()
|
||||||
|
assert.Contains(t, out, "wing: a")
|
||||||
|
assert.Contains(t, out, "supersedes: abc123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterRenderPreservesCustomFields(t *testing.T) {
|
||||||
|
src := "---\nwing: a\nhall: facts\ncustom_field: keep-me\ntags: [x, y]\n---\nbody\n"
|
||||||
|
fm, _ := parseFrontmatter(src)
|
||||||
|
fm.set("updated_at", "2026-06-22T00:00:00Z")
|
||||||
|
|
||||||
|
out := fm.render()
|
||||||
|
assert.Contains(t, out, "custom_field: keep-me")
|
||||||
|
assert.Contains(t, out, "tags: [x, y]")
|
||||||
|
assert.Contains(t, out, "updated_at: 2026-06-22T00:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterRenderRoundTrips(t *testing.T) {
|
||||||
|
src := "---\nwing: a\nhall: facts\n---\n"
|
||||||
|
fm, _ := parseFrontmatter(src)
|
||||||
|
assert.Equal(t, src, fm.render())
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedNote writes a note directly to disk and returns its relPath.
|
||||||
|
func seedNote(t *testing.T, brainDir, rel, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(brainDir, filepath.FromSlash(rel))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteSupersedesAndStamps(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n")
|
||||||
|
|
||||||
|
relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "# New\n\nnew body\n",
|
||||||
|
Reason: "facts changed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, rel, relPath)
|
||||||
|
assert.NotEmpty(t, hash)
|
||||||
|
assert.NotEmpty(t, priorHash)
|
||||||
|
assert.NotEqual(t, hash, priorHash)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
// Body replaced.
|
||||||
|
assert.Contains(t, s, "# New")
|
||||||
|
assert.NotContains(t, s, "old body")
|
||||||
|
// Prior fields preserved.
|
||||||
|
assert.Contains(t, s, "wing: jepa-fx")
|
||||||
|
assert.Contains(t, s, "hall: facts")
|
||||||
|
assert.Contains(t, s, "created_at: 2026-01-01T00:00:00Z")
|
||||||
|
assert.Contains(t, s, "custom: keep-me")
|
||||||
|
// Supersession stamped.
|
||||||
|
assert.Contains(t, s, "updated_at:")
|
||||||
|
assert.Contains(t, s, "supersedes: "+priorHash)
|
||||||
|
assert.Contains(t, s, "supersede_reason: facts changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\n---\nold\n")
|
||||||
|
|
||||||
|
relPath, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Wing: "jepa-fx", Hall: "facts", Slug: "val-vol",
|
||||||
|
Content: "new\n",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteErrorsOnMissingAndDoesNotCreate(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Wing: "jepa-fx", Hall: "facts", Slug: "ghost",
|
||||||
|
Content: "x\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "does not exist")
|
||||||
|
|
||||||
|
// No file created.
|
||||||
|
_, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "missing-target update must not create a note")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsTraversal(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: "../escape.md",
|
||||||
|
Content: "x\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadNoteReturnsFrontmatterBodyHash(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/n.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\n---\n# Body\n\ntext\n")
|
||||||
|
|
||||||
|
fm, body, hash, err := ReadNote(brainDir, rel)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "jepa-fx", fm["wing"])
|
||||||
|
assert.Equal(t, "facts", fm["hall"])
|
||||||
|
assert.Equal(t, "# Body\n\ntext\n", body)
|
||||||
|
|
||||||
|
// Hash matches ContentHash of the raw bytes on disk (round-trip).
|
||||||
|
raw, _ := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
assert.Equal(t, ContentHash(raw), hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadNoteRejectsTraversal(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
_, _, _, err := ReadNote(brainDir, "../../etc/passwd")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateThenReadRoundTripsHash(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
||||||
|
|
||||||
|
_, hash, _, err := UpdateNote(brainDir, UpdateNoteOptions{Path: rel, Content: "new\n"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, _, readHash, err := ReadNote(brainDir, rel)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentHashStable(t *testing.T) {
|
||||||
|
assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc")))
|
||||||
|
assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd")))
|
||||||
|
assert.True(t, strings.HasPrefix(ContentHash([]byte("")), "")) // hex, non-panicking
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user