From 41fa92f54426930a32bf866f734b2855bf467d90 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sat, 11 Apr 2026 23:26:26 +0200 Subject: [PATCH] agent: add pluggable memory backend with flat-dir fallback Introduces memory_remember and memory_recall built-in tools on the reasoning server. A MemoryBackend interface allows swapping backends: dispatchMemoryBackend routes through a registered MCP server (e.g. denote-mcp) when available; FlatDirBackend writes dated markdown files to ~/.local/share/ollie/memory/ as a zero-config fallback. Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/core.go | 12 ++++ pkg/agent/memory.go | 62 ++++++++++++++++++ pkg/tools/builtin.go | 30 +++++++++ pkg/tools/reasoning/memory.go | 114 ++++++++++++++++++++++++++++++++++ pkg/tools/reasoning/server.go | 62 +++++++++++++++++- pkg/tools/tools.go | 13 ++++ 6 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 pkg/agent/memory.go create mode 100644 pkg/tools/reasoning/memory.go diff --git a/pkg/agent/core.go b/pkg/agent/core.go index bee6728..12e3f48 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -21,6 +21,7 @@ import ( "ollie/pkg/config" "ollie/pkg/mcp" "ollie/pkg/tools" + "ollie/pkg/tools/reasoning" ) // systemPrompt builds the full system prompt for a given tool set. @@ -122,6 +123,17 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, workdir string, opts setter.SetPlanBackend(eo.fallbackPlan) } } + + // Wire memory backend to the reasoning server. + // If memory_remember is available via MCP, use the dispatch backend. + // Otherwise fall back to the flat-dir backend (always available). + if setter, ok := rs.(tools.MemoryBackendSetter); ok { + if memServer, ok := serverOf["memory_remember"]; ok { + setter.SetMemoryBackend(&dispatchMemoryBackend{d: d, server: memServer}) + } else { + setter.SetMemoryBackend(reasoning.NewFlatDirBackend("")) + } + } } hooks := Hooks{} diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go new file mode 100644 index 0000000..aff7716 --- /dev/null +++ b/pkg/agent/memory.go @@ -0,0 +1,62 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "ollie/pkg/tools" +) + +// dispatchMemoryBackend implements tools.MemoryBackend by dispatching +// memory_remember and memory_recall calls through the agent's dispatcher to a +// registered MCP server (e.g. denote-mcp). +type dispatchMemoryBackend struct { + d tools.Dispatcher + server string // name of the memory MCP server +} + +// Remember dispatches memory_remember to the registered MCP server. +func (b *dispatchMemoryBackend) Remember(ctx context.Context, title string, tags []string, body string) (string, error) { + args := map[string]any{ + "title": title, + "tags": tags, + "body": body, + } + raw, err := json.Marshal(args) + if err != nil { + return "", err + } + result, err := b.d.Dispatch(ctx, b.server, "memory_remember", raw) + if err != nil { + return "", err + } + return extractText(result) +} + +// Recall dispatches memory_recall to the registered MCP server. +func (b *dispatchMemoryBackend) Recall(ctx context.Context, query string) (string, error) { + args := map[string]any{"query": query} + raw, err := json.Marshal(args) + if err != nil { + return "", err + } + result, err := b.d.Dispatch(ctx, b.server, "memory_recall", raw) + if err != nil { + return "", err + } + return extractText(result) +} + +// extractText pulls the first text content item out of an MCP tool response. +func extractText(result json.RawMessage) (string, error) { + var resp struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(result, &resp); err != nil || len(resp.Content) == 0 { + return "", fmt.Errorf("unexpected response: %s", string(result)) + } + return resp.Content[0].Text, nil +} diff --git a/pkg/tools/builtin.go b/pkg/tools/builtin.go index 299900f..8c49204 100644 --- a/pkg/tools/builtin.go +++ b/pkg/tools/builtin.go @@ -60,6 +60,36 @@ func ExecuteDefs(toolsPath string) []ToolInfo { } } +// MemoryDefs returns the ToolInfo definitions for the memory_* built-in tools. +func MemoryDefs() []ToolInfo { + return []ToolInfo{ + { + Name: "memory_remember", + Description: "Save a memory that persists across sessions. Provide a descriptive title, relevant tags, and a concise body. Choose tags carefully — they are the primary recall mechanism. Prefer specific, stable terms over generic ones.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["title", "tags", "body"], + "properties": { + "title": {"type": "string", "description": "Descriptive title. Should answer 'what is this about' on its own."}, + "tags": {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Tags for recall. Use specific, stable terms you would search for later."}, + "body": {"type": "string", "description": "Memory content. Write concisely — key facts, decisions, and context. Not a transcript."} + } + }`), + }, + { + Name: "memory_recall", + Description: "Search memories by keyword. Matches against titles and tags only — body content is not searched. Use specific terms likely to appear in titles or tags at write time.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string", "description": "Keyword to search for in memory titles and tags."} + } + }`), + }, + } +} + // ReasoningDefs returns the ToolInfo definitions for the reasoning_* built-in tools. func ReasoningDefs() []ToolInfo { return []ToolInfo{ diff --git a/pkg/tools/reasoning/memory.go b/pkg/tools/reasoning/memory.go new file mode 100644 index 0000000..d1065d3 --- /dev/null +++ b/pkg/tools/reasoning/memory.go @@ -0,0 +1,114 @@ +package reasoning + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// FlatDirBackend implements tools.MemoryBackend using a plain directory of +// markdown files. Filenames follow a denote-like convention: +// +// YYYYMMddTHHmmss--title-slug__tag1_tag2.md +// +// Recall searches filenames only — no body reads. Tag and title are both +// encoded in the filename, making it the sole search surface. +type FlatDirBackend struct { + dir string +} + +// NewFlatDirBackend returns a FlatDirBackend rooted at dir. If dir is empty, +// OLLIE_MEMORY_PATH is used, falling back to ~/.local/share/ollie/memory/. +func NewFlatDirBackend(dir string) *FlatDirBackend { + if dir == "" { + dir = os.Getenv("OLLIE_MEMORY_PATH") + } + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".local", "share", "ollie", "memory") + } + return &FlatDirBackend{dir: dir} +} + +// Remember writes a new memory file and returns its filename. +func (b *FlatDirBackend) Remember(_ context.Context, title string, tags []string, body string) (string, error) { + if err := os.MkdirAll(b.dir, 0700); err != nil { + return "", fmt.Errorf("memory dir: %w", err) + } + + slug := slugify(title) + ts := time.Now().Format("20060102T150405") + + name := ts + "--" + slug + if len(tags) > 0 { + name += "__" + strings.Join(tags, "_") + } + name += ".md" + + content := buildFlatNote(title, tags, body) + path := filepath.Join(b.dir, name) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + return "", fmt.Errorf("write memory: %w", err) + } + return name, nil +} + +// Recall searches memory filenames for the query and returns matching file +// contents. Body content is not searched. +func (b *FlatDirBackend) Recall(_ context.Context, query string) (string, error) { + entries, err := os.ReadDir(b.dir) + if err != nil { + if os.IsNotExist(err) { + return "no memories found matching: " + query, nil + } + return "", fmt.Errorf("read memory dir: %w", err) + } + + q := strings.ToLower(query) + var results []string + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + if !strings.Contains(strings.ToLower(e.Name()), q) { + continue + } + data, err := os.ReadFile(filepath.Join(b.dir, e.Name())) + if err != nil { + continue + } + results = append(results, fmt.Sprintf("file: %s\n\n%s", e.Name(), strings.TrimSpace(string(data)))) + } + + if len(results) == 0 { + return "no memories found matching: " + query, nil + } + return strings.Join(results, "\n\n---\n\n"), nil +} + +var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`) + +// slugify converts a title to a lowercase hyphen-separated slug. +func slugify(s string) string { + s = strings.ToLower(s) + s = nonAlnum.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + return s +} + +// buildFlatNote formats a markdown note with a minimal frontmatter header. +func buildFlatNote(title string, tags []string, body string) string { + var sb strings.Builder + fmt.Fprintf(&sb, "# %s\n\n", title) + if len(tags) > 0 { + fmt.Fprintf(&sb, "tags: %s\n\n", strings.Join(tags, ", ")) + } + sb.WriteString(strings.TrimSpace(body)) + sb.WriteByte('\n') + return sb.String() +} diff --git a/pkg/tools/reasoning/server.go b/pkg/tools/reasoning/server.go index faa110e..a87faa3 100644 --- a/pkg/tools/reasoning/server.go +++ b/pkg/tools/reasoning/server.go @@ -17,14 +17,21 @@ type Server struct { // Plan is the optional task persistence backend. When nil, reasoning_plan // produces an in-context plan only. Plan tools.PlanBackend + + // Memory is the memory persistence backend. Always non-nil after + // BuildAgentEnv wires it; defaults to a FlatDirBackend. + Memory tools.MemoryBackend } // SetPlanBackend implements tools.PlanBackendSetter. func (s *Server) SetPlanBackend(b tools.PlanBackend) { s.Plan = b } +// SetMemoryBackend implements tools.MemoryBackendSetter. +func (s *Server) SetMemoryBackend(b tools.MemoryBackend) { s.Memory = b } + // ListTools implements tools.Server. func (s *Server) ListTools() ([]tools.ToolInfo, error) { - return tools.ReasoningDefs(), nil + return append(tools.ReasoningDefs(), tools.MemoryDefs()...), nil } // CallTool implements tools.Server. @@ -45,6 +52,18 @@ func (s *Server) CallTool(ctx context.Context, tool string, args json.RawMessage if err != nil { text = "error: " + err.Error() } + case "memory_remember": + var err error + text, err = s.remember(ctx, args) + if err != nil { + text = "error: " + err.Error() + } + case "memory_recall": + var err error + text, err = s.recall(ctx, args) + if err != nil { + text = "error: " + err.Error() + } default: text = "error: unknown tool: " + tool } @@ -130,5 +149,46 @@ func (s *Server) plan(ctx context.Context, args json.RawMessage) (string, error) return sb.String(), nil } +func (s *Server) remember(ctx context.Context, args json.RawMessage) (string, error) { + var a struct { + Title string `json:"title"` + Tags []string `json:"tags"` + Body string `json:"body"` + } + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("bad args: %w", err) + } + if a.Title == "" { + return "", fmt.Errorf("'title' is required") + } + if len(a.Tags) == 0 { + return "", fmt.Errorf("'tags' must be non-empty") + } + if a.Body == "" { + return "", fmt.Errorf("'body' is required") + } + if s.Memory == nil { + return "", fmt.Errorf("no memory backend configured") + } + return s.Memory.Remember(ctx, a.Title, a.Tags, a.Body) +} + +func (s *Server) recall(ctx context.Context, args json.RawMessage) (string, error) { + var a struct { + Query string `json:"query"` + } + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("bad args: %w", err) + } + if a.Query == "" { + return "", fmt.Errorf("'query' is required") + } + if s.Memory == nil { + return "", fmt.Errorf("no memory backend configured") + } + return s.Memory.Recall(ctx, a.Query) +} + var _ tools.Server = (*Server)(nil) var _ tools.PlanBackendSetter = (*Server)(nil) +var _ tools.MemoryBackendSetter = (*Server)(nil) diff --git a/pkg/tools/tools.go b/pkg/tools/tools.go index 3c434b4..0dc0ff4 100644 --- a/pkg/tools/tools.go +++ b/pkg/tools/tools.go @@ -125,6 +125,19 @@ type PlanBackendSetter interface { SetPlanBackend(PlanBackend) } +// MemoryBackend persists and retrieves memories. Used by memory_remember and +// memory_recall. A flat-file fallback is always available; denotesrv is +// preferred when its mount is accessible. +type MemoryBackend interface { + Remember(ctx context.Context, title string, tags []string, body string) (string, error) + Recall(ctx context.Context, query string) (string, error) +} + +// MemoryBackendSetter is implemented by tool servers that accept a MemoryBackend. +type MemoryBackendSetter interface { + SetMemoryBackend(MemoryBackend) +} + // WorkDirSetter is implemented by tool servers that accept a dynamic working // directory. SetWorkDir updates the directory used for subsequent tool calls. type WorkDirSetter interface {