diff --git a/ingestion/internal/api/frontmatter.go b/ingestion/internal/api/frontmatter.go new file mode 100644 index 0000000..6569f15 --- /dev/null +++ b/ingestion/internal/api/frontmatter.go @@ -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() +} diff --git a/ingestion/internal/api/frontmatter_test.go b/ingestion/internal/api/frontmatter_test.go new file mode 100644 index 0000000..6bcb2e3 --- /dev/null +++ b/ingestion/internal/api/frontmatter_test.go @@ -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()) +} diff --git a/ingestion/internal/api/update.go b/ingestion/internal/api/update.go new file mode 100644 index 0000000..9fd88f8 --- /dev/null +++ b/ingestion/internal/api/update.go @@ -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 +} diff --git a/ingestion/internal/api/update_test.go b/ingestion/internal/api/update_test.go new file mode 100644 index 0000000..d677a29 --- /dev/null +++ b/ingestion/internal/api/update_test.go @@ -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 +}