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:
parent
ae82e0eff0
commit
41fa92f544
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Reference in New Issue