feat(memory): add vault core with frontmatter parsing and wikilink extraction

Add the memory package with NoteMeta struct, hand-rolled YAML frontmatter
parser, bracket list parser, and [[wikilink]] extractor. These are the
building blocks for the Obsidian-inspired memory vault system.
This commit is contained in:
Paul De Velder 2026-02-23 16:07:43 +01:00
parent 12ba462ae5
commit 4004ee92fb
2 changed files with 319 additions and 0 deletions

114
pkg/memory/vault.go Normal file
View file

@ -0,0 +1,114 @@
package memory
import "strings"
// NoteMeta represents parsed frontmatter metadata from a single markdown note.
type NoteMeta struct {
Title string
Created string
Updated string
Tags []string
Aliases []string
RelPath string
Links []string
}
// Vault manages the memory vault: scanning, indexing, and searching notes.
type Vault struct {
memoryDir string
}
// NewVault creates a new Vault rooted at the given memory directory.
func NewVault(memoryDir string) *Vault {
return &Vault{memoryDir: memoryDir}
}
// ParseFrontmatter extracts YAML frontmatter from markdown content.
// Frontmatter is delimited by "---\n" at the start and a subsequent "\n---".
// Returns the parsed metadata and the body (everything after the closing delimiter).
// If no valid frontmatter is found, returns a zero NoteMeta and the full content as body.
func ParseFrontmatter(content string) (NoteMeta, string) {
if !strings.HasPrefix(content, "---\n") && !strings.HasPrefix(content, "---\r\n") {
return NoteMeta{}, content
}
// Find closing delimiter after the opening "---\n"
rest := content[4:]
endIdx := strings.Index(rest, "\n---")
if endIdx < 0 {
return NoteMeta{}, content
}
fmBlock := rest[:endIdx]
body := rest[endIdx+4:] // skip "\n---"
meta := NoteMeta{}
for _, line := range strings.Split(fmBlock, "\n") {
line = strings.TrimSpace(line)
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
continue
}
key := strings.TrimSpace(line[:colonIdx])
val := strings.TrimSpace(line[colonIdx+1:])
switch key {
case "title":
meta.Title = val
case "created":
meta.Created = val
case "updated":
meta.Updated = val
case "tags":
meta.Tags = parseBracketList(val)
case "aliases":
meta.Aliases = parseBracketList(val)
}
}
return meta, body
}
// parseBracketList parses a bracket-delimited list like "[a, b, c]" into a string slice.
// Also handles plain comma-separated values without brackets.
// Returns nil for empty input or empty brackets.
func parseBracketList(s string) []string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
if s == "" {
return nil
}
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
if len(result) == 0 {
return nil
}
return result
}
// ExtractWikilinks finds all [[target]] references in body text.
// Returns a slice of link targets with the brackets stripped.
func ExtractWikilinks(body string) []string {
var links []string
for {
start := strings.Index(body, "[[")
if start < 0 {
break
}
end := strings.Index(body[start+2:], "]]")
if end < 0 {
break
}
link := body[start+2 : start+2+end]
links = append(links, link)
body = body[start+2+end+2:]
}
return links
}

205
pkg/memory/vault_test.go Normal file
View file

@ -0,0 +1,205 @@
package memory
import (
"testing"
)
// --- ParseFrontmatter tests ---
func TestParseFrontmatter_Valid(t *testing.T) {
content := `---
title: Go Error Patterns
created: 2026-02-23
updated: 2026-02-23
tags: [go, patterns, errors]
aliases: [error-handling, go-errors]
---
# Go Error Patterns
Content here.`
meta, body := ParseFrontmatter(content)
if meta.Title != "Go Error Patterns" {
t.Errorf("Title = %q, want %q", meta.Title, "Go Error Patterns")
}
if meta.Created != "2026-02-23" {
t.Errorf("Created = %q, want %q", meta.Created, "2026-02-23")
}
if meta.Updated != "2026-02-23" {
t.Errorf("Updated = %q, want %q", meta.Updated, "2026-02-23")
}
assertStringSlice(t, "Tags", meta.Tags, []string{"go", "patterns", "errors"})
assertStringSlice(t, "Aliases", meta.Aliases, []string{"error-handling", "go-errors"})
expectedBody := "\n\n# Go Error Patterns\n\nContent here."
if body != expectedBody {
t.Errorf("Body = %q, want %q", body, expectedBody)
}
}
func TestParseFrontmatter_Empty(t *testing.T) {
content := "# Just a heading\n\nSome content."
meta, body := ParseFrontmatter(content)
if meta.Title != "" {
t.Errorf("Title = %q, want empty", meta.Title)
}
if body != content {
t.Errorf("Body should equal original content")
}
}
func TestParseFrontmatter_Partial(t *testing.T) {
content := `---
title: Only Title
tags: [single]
---
Body text.`
meta, body := ParseFrontmatter(content)
if meta.Title != "Only Title" {
t.Errorf("Title = %q, want %q", meta.Title, "Only Title")
}
if meta.Created != "" {
t.Errorf("Created = %q, want empty", meta.Created)
}
if meta.Updated != "" {
t.Errorf("Updated = %q, want empty", meta.Updated)
}
assertStringSlice(t, "Tags", meta.Tags, []string{"single"})
if len(meta.Aliases) != 0 {
t.Errorf("Aliases = %v, want empty", meta.Aliases)
}
expectedBody := "\n\nBody text."
if body != expectedBody {
t.Errorf("Body = %q, want %q", body, expectedBody)
}
}
func TestParseFrontmatter_NoClosingDelimiter(t *testing.T) {
content := `---
title: Broken
tags: [oops]
No closing delimiter here.`
meta, body := ParseFrontmatter(content)
if meta.Title != "" {
t.Errorf("Title = %q, want empty for malformed frontmatter", meta.Title)
}
if body != content {
t.Errorf("Body should equal original content for malformed frontmatter")
}
}
func TestParseFrontmatter_EmptyContent(t *testing.T) {
meta, body := ParseFrontmatter("")
if meta.Title != "" {
t.Errorf("Title = %q, want empty", meta.Title)
}
if body != "" {
t.Errorf("Body = %q, want empty", body)
}
}
func TestParseFrontmatter_OnlyFrontmatter(t *testing.T) {
content := `---
title: No Body
---
`
meta, body := ParseFrontmatter(content)
if meta.Title != "No Body" {
t.Errorf("Title = %q, want %q", meta.Title, "No Body")
}
if body != "\n" {
t.Errorf("Body = %q, want %q", body, "\n")
}
}
// --- parseBracketList tests ---
func TestParseBracketList(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{"multiple items", "[a, b, c]", []string{"a", "b", "c"}},
{"single item", "[only]", []string{"only"}},
{"empty brackets", "[]", nil},
{"empty string", "", nil},
{"spaces around items", "[ foo , bar , baz ]", []string{"foo", "bar", "baz"}},
{"no brackets", "plain, text", []string{"plain", "text"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseBracketList(tt.input)
assertStringSlice(t, "result", got, tt.want)
})
}
}
// --- ExtractWikilinks tests ---
func TestExtractWikilinks_None(t *testing.T) {
links := ExtractWikilinks("No links here, just plain text.")
if len(links) != 0 {
t.Errorf("got %v, want empty", links)
}
}
func TestExtractWikilinks_Single(t *testing.T) {
links := ExtractWikilinks("See [[go-patterns]] for details.")
assertStringSlice(t, "links", links, []string{"go-patterns"})
}
func TestExtractWikilinks_Multiple(t *testing.T) {
body := `Check [[error-handling]] and [[testing-guide]].
Also see [[deployment]].`
links := ExtractWikilinks(body)
assertStringSlice(t, "links", links, []string{"error-handling", "testing-guide", "deployment"})
}
func TestExtractWikilinks_Unclosed(t *testing.T) {
links := ExtractWikilinks("Broken [[link without closing.")
if len(links) != 0 {
t.Errorf("got %v, want empty for unclosed link", links)
}
}
func TestExtractWikilinks_Empty(t *testing.T) {
links := ExtractWikilinks("")
if len(links) != 0 {
t.Errorf("got %v, want empty", links)
}
}
func TestExtractWikilinks_Adjacent(t *testing.T) {
links := ExtractWikilinks("[[one]][[two]]")
assertStringSlice(t, "links", links, []string{"one", "two"})
}
// --- Test helpers ---
func assertStringSlice(t *testing.T, name string, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Errorf("%s: got %v (len %d), want %v (len %d)", name, got, len(got), want, len(want))
return
}
for i := range got {
if got[i] != want[i] {
t.Errorf("%s[%d] = %q, want %q", name, i, got[i], want[i])
}
}
}