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:
parent
12ba462ae5
commit
4004ee92fb
2 changed files with 319 additions and 0 deletions
114
pkg/memory/vault.go
Normal file
114
pkg/memory/vault.go
Normal 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
205
pkg/memory/vault_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue