diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go index 7af073f..48a65b3 100644 --- a/ingestion/internal/api/handler.go +++ b/ingestion/internal/api/handler.go @@ -156,17 +156,40 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) { return "", fmt.Errorf("create hall dir: %w", err) } + existingFields, body := splitFrontmatter(opts.Content) + existingByKey := make(map[string]frontmatterField, len(existingFields)) + for _, f := range existingFields { + existingByKey[f.key] = f + } + emitted := make(map[string]bool, 6) + var fm strings.Builder fm.WriteString("---\n") fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing)) fmt.Fprintf(&fm, "hall: %s\n", opts.Hall) fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339)) - if opts.Type != "" { - fmt.Fprintf(&fm, "type: %s\n", opts.Type) - } - if opts.Domain != "" { - fmt.Fprintf(&fm, "domain: %s\n", opts.Domain) + emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true + + // writeField merges one key: opts.Content's own value (if the note already + // carries this field in its own frontmatter) always wins over the fallback, + // so promotion/extraction-step metadata survives verbatim instead of being + // shadowed by a second, stacked frontmatter block (#86). + writeField := func(key, fallback string) { + emitted[key] = true + if f, ok := existingByKey[key]; ok { + for _, line := range f.lines { + fm.WriteString(line) + fm.WriteString("\n") + } + return + } + if fallback != "" { + fmt.Fprintf(&fm, "%s: %s\n", key, fallback) + } } + writeField("type", opts.Type) + writeField("domain", opts.Domain) + sourceType := opts.SourceType if sourceType == "" && opts.Hall == "facts" { // Most hall=facts entries are first-party (an eval/benchmark the @@ -175,18 +198,69 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) { // citation-needing entry (brain-gardener#7). sourceType = "internal" } - if sourceType != "" { - fmt.Fprintf(&fm, "source_type: %s\n", sourceType) + writeField("source_type", sourceType) + + for _, f := range existingFields { + if emitted[f.key] { + continue + } + for _, line := range f.lines { + fm.WriteString(line) + fm.WriteString("\n") + } } fm.WriteString("---\n") - if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil { + if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil { return "", fmt.Errorf("write: %w", err) } rel, _ := filepath.Rel(brainDir, dest) return filepath.ToSlash(rel), nil } +// frontmatterField is one top-level YAML key from a frontmatter block, +// along with its raw line and any indented continuation lines (e.g. a +// bulleted list value spanning multiple lines). +type frontmatterField struct { + key string + lines []string +} + +// splitFrontmatter splits a leading "---\n...\n---\n" YAML block out of +// content, returning its top-level fields in original order and the +// remaining body. If content has no leading frontmatter block, fields is +// nil and body is content unchanged. +func splitFrontmatter(content string) (fields []frontmatterField, body string) { + if !strings.HasPrefix(content, "---\n") { + return nil, content + } + + lines := strings.Split(content, "\n") + i := 1 + var cur *frontmatterField + for ; i < len(lines); i++ { + line := lines[i] + if strings.TrimSpace(line) == "---" { + i++ + break + } + if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + if cur != nil { + fields = append(fields, *cur) + } + key, _, _ := strings.Cut(line, ":") + cur = &frontmatterField{key: strings.TrimSpace(key), lines: []string{line}} + } else if cur != nil { + cur.lines = append(cur.lines, line) + } + } + if cur != nil { + fields = append(fields, *cur) + } + body = strings.Join(lines[i:], "\n") + return fields, body +} + // writeLegacyNote preserves the original brain/knowledge/ behaviour for // callers that have not adopted the wing/hall taxonomy. func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) { diff --git a/ingestion/internal/api/handler_test.go b/ingestion/internal/api/handler_test.go index b0b1917..fa12c48 100644 --- a/ingestion/internal/api/handler_test.go +++ b/ingestion/internal/api/handler_test.go @@ -186,6 +186,48 @@ func TestWriteNote_HallRouteOmitsSourceTypeForNonFactsHalls(t *testing.T) { assert.NotContains(t, string(got), "source_type") } +func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testing.T) { + dir := t.TempDir() + + rel, err := api.WriteNote(dir, api.WriteNoteOptions{ + Content: "---\ntitle: act_runner host-executor\ntags: [gitea-actions, act_runner]\n---\n\n# Body\n\nSome content.\n", + Filename: "act-runner-host-executor", + Wing: "homelab", + Hall: "failures", + }) + require.NoError(t, err) + + got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) + require.NoError(t, err) + body := string(got) + + // exactly one frontmatter block: only two "---" delimiter lines total + assert.Equal(t, 2, strings.Count(body, "---\n"), "expected a single merged frontmatter block, not stacked blocks") + assert.Contains(t, body, "wing: homelab") + assert.Contains(t, body, "hall: failures") + assert.Contains(t, body, "title: act_runner host-executor") + assert.Contains(t, body, "tags: [gitea-actions, act_runner]") + assert.Contains(t, body, "# Body") +} + +func TestWriteNote_HallRouteExistingTypeWinsOverOptsType(t *testing.T) { + dir := t.TempDir() + + rel, err := api.WriteNote(dir, api.WriteNoteOptions{ + Content: "---\ntype: hypothesis\n---\n\nBody.\n", + Filename: "note", + Wing: "agentsquad", + Hall: "decisions", + Type: "decision", // should lose to content's own "type: hypothesis" + }) + require.NoError(t, err) + + got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) + require.NoError(t, err) + assert.Contains(t, string(got), "type: hypothesis") + assert.NotContains(t, string(got), "type: decision") +} + func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) { dir, h := setup(t) body, _ := json.Marshal(map[string]any{"content": "auto name"})