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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-11 23:26:26 +02:00
parent ae82e0eff0
commit 41fa92f544
6 changed files with 292 additions and 1 deletions

View File

@ -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{}

62
pkg/agent/memory.go Normal file
View File

@ -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
}

View File

@ -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{

View File

@ -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()
}

View File

@ -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)

View File

@ -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 {