Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ Memoh/
│ │ ├── prompts/ # Prompt templates (Markdown, with partials prefixed by _)
│ │ │ ├── system_chat.md, system_discuss.md, system_heartbeat.md, system_schedule.md, system_subagent.md
│ │ │ ├── _tools.md, _memory.md, _contacts.md, _schedule_task.md, _subagent.md
│ │ │ ├── heartbeat.md, schedule.md
│ │ │ └── memory_extract.md, memory_update.md
│ │ │ └── heartbeat.md, schedule.md
│ │ └── tools/ # Tool providers (ToolProvider interface)
│ │ ├── message.go # Send message tool
│ │ ├── contacts.go # Contact list tool
Expand Down
19 changes: 3 additions & 16 deletions cmd/agent/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,8 @@ func provideMemoryLLM(modelsService *models.Service, settingsService *settings.S
func provideMemoryProviderRegistry(log *slog.Logger, llm memprovider.LLM, chatService *conversation.Service, accountService *accounts.Service, provider bridge.Provider, queries dbstore.Queries, cfg config.Config) *memprovider.Registry {
registry := memprovider.NewRegistry(log)
fileStore := storefs.New(log, provider)
var fileRuntime any
if provider != nil {
fileRuntime = membuiltin.NewFileRuntime(fileStore)
}
registry.RegisterFactory(string(memprovider.ProviderBuiltin), func(_ string, providerConfig map[string]any) (memprovider.Provider, error) {
runtime, err := membuiltin.NewBuiltinRuntimeFromConfig(log, providerConfig, fileRuntime, fileStore, queries, cfg)
runtime, err := membuiltin.NewBuiltinRuntimeFromConfig(log, providerConfig, fileStore, queries, cfg)
if err != nil {
return nil, err
}
Expand All @@ -285,7 +281,7 @@ func provideMemoryProviderRegistry(log *slog.Logger, llm memprovider.LLM, chatSe
registry.RegisterFactory(string(memprovider.ProviderOpenViking), func(_ string, providerConfig map[string]any) (memprovider.Provider, error) {
return memopenviking.NewOpenVikingProvider(log, providerConfig)
})
defaultProvider := membuiltin.NewBuiltinProvider(log, fileRuntime, chatService, accountService)
defaultProvider := membuiltin.NewBuiltinProvider(log, membuiltin.NewFileRuntime(fileStore), chatService, accountService)
defaultProvider.SetLLM(llm)
registry.Register("__builtin_default__", defaultProvider)
return registry
Expand Down Expand Up @@ -688,11 +684,10 @@ func provideToolProviders(log *slog.Logger, channelManager *channel.Manager, reg
}
}

func provideMemoryHandler(log *slog.Logger, botService *bots.Service, accountService *accounts.Service, _ config.Config, provider bridge.Provider, memoryRegistry *memprovider.Registry, settingsService *settings.Service, _ *handlers.ContainerdHandler) *handlers.MemoryHandler {
func provideMemoryHandler(log *slog.Logger, botService *bots.Service, accountService *accounts.Service, _ config.Config, memoryRegistry *memprovider.Registry, settingsService *settings.Service, _ *handlers.ContainerdHandler) *handlers.MemoryHandler {
h := handlers.NewMemoryHandler(log, botService, accountService)
h.SetMemoryRegistry(memoryRegistry)
h.SetSettingsService(settingsService)
h.SetMCPClientProvider(provider)
return h
}

Expand Down Expand Up @@ -1209,14 +1204,6 @@ func (c *lazyLLMClient) Compact(ctx context.Context, req memprovider.CompactRequ
return client.Compact(ctx, req)
}

func (c *lazyLLMClient) DetectLanguage(ctx context.Context, text string) (string, error) {
client, err := c.resolve(ctx, "")
if err != nil {
return "", err
}
return client.DetectLanguage(ctx, text)
}

func (c *lazyLLMClient) resolve(ctx context.Context, botID string) (memprovider.LLM, error) {
if c.modelsService == nil || c.queries == nil {
return nil, errors.New("models service not configured")
Expand Down
5 changes: 0 additions & 5 deletions internal/agent/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ var (
scheduleTmpl string
heartbeatTmpl string

MemoryExtractPrompt string
MemoryUpdatePrompt string

includes map[string]string
)

Expand All @@ -43,8 +40,6 @@ func init() {
modeSubagentTmpl = mustReadPrompt("prompts/mode_subagent.md")
scheduleTmpl = mustReadPrompt("prompts/schedule.md")
heartbeatTmpl = mustReadPrompt("prompts/heartbeat.md")
MemoryExtractPrompt = mustReadPrompt("prompts/memory_extract.md")
MemoryUpdatePrompt = mustReadPrompt("prompts/memory_update.md")

includes = map[string]string{
"_memory": mustReadPrompt("prompts/_memory.md"),
Expand Down
12 changes: 0 additions & 12 deletions internal/handlers/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import (
"github.com/memohai/memoh/internal/accounts"
"github.com/memohai/memoh/internal/bots"
memprovider "github.com/memohai/memoh/internal/memory/adapters"
storefs "github.com/memohai/memoh/internal/memory/storefs"
"github.com/memohai/memoh/internal/settings"
"github.com/memohai/memoh/internal/workspace/bridge"
)

// MemoryHandler handles memory CRUD operations scoped by bot.
Expand All @@ -23,7 +21,6 @@ type MemoryHandler struct {
accountService *accounts.Service
settingsService *settings.Service
memoryRegistry *memprovider.Registry
memoryStore *storefs.Service
logger *slog.Logger
}

Expand Down Expand Up @@ -115,15 +112,6 @@ func (h *MemoryHandler) resolveProvider(ctx context.Context, botID string) (memp
return p, nil
}

// SetMCPClientProvider sets the gRPC client provider for filesystem persistence.
func (h *MemoryHandler) SetMCPClientProvider(p bridge.Provider) {
if p == nil {
h.memoryStore = nil
return
}
h.memoryStore = storefs.New(h.logger, p)
}

// Register registers chat-level memory routes.
func (h *MemoryHandler) Register(e *echo.Echo) {
chatGroup := e.Group("/bots/:bot_id/memory")
Expand Down
14 changes: 5 additions & 9 deletions internal/memory/adapters/builtin/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,18 @@ const (

// BuiltinProvider wraps the existing Service as a Provider.
type BuiltinProvider struct {
service memoryRuntime
service Runtime
llm adapters.LLM
chatAccessor conversation.Accessor
adminChecker AdminChecker
logger *slog.Logger
packer contextPackerConfig
}

// memoryRuntime is the runtime memory backend required by the builtin provider.
// Runtime is the runtime memory backend required by the builtin provider.
// It is intentionally defined as an interface to decouple provider wiring from
// concrete service structs in the memory package.
type memoryRuntime interface {
type Runtime interface {
Add(ctx context.Context, req adapters.AddRequest) (adapters.SearchResponse, error)
Search(ctx context.Context, req adapters.SearchRequest) (adapters.SearchResponse, error)
GetAll(ctx context.Context, req adapters.GetAllRequest) (adapters.SearchResponse, error)
Expand All @@ -59,17 +59,13 @@ type AdminChecker interface {
IsAdmin(ctx context.Context, channelIdentityID string) (bool, error)
}

func NewBuiltinProvider(log *slog.Logger, service any, chatAccessor conversation.Accessor, adminChecker AdminChecker) *BuiltinProvider {
func NewBuiltinProvider(log *slog.Logger, service Runtime, chatAccessor conversation.Accessor, adminChecker AdminChecker) *BuiltinProvider {
if log == nil {
log = slog.Default()
}
logger := log.With(slog.String("provider", BuiltinType))
runtimeService, ok := service.(memoryRuntime)
if service != nil && !ok {
logger.Warn("service does not implement memoryRuntime; provider will operate without a backend")
}
return &BuiltinProvider{
service: runtimeService,
service: service,
chatAccessor: chatAccessor,
adminChecker: adminChecker,
logger: logger,
Expand Down
23 changes: 5 additions & 18 deletions internal/memory/adapters/builtin/builtin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,18 +337,6 @@ func TestIntFromConfig(t *testing.T) {
}
}

func TestBuiltinProviderBadServiceTypeDoesNotPanic(t *testing.T) {
t.Parallel()
p := NewBuiltinProvider(slog.Default(), "not a runtime", nil, nil)
if p.service != nil {
t.Fatal("expected nil service for non-memoryRuntime value")
}
_, err := p.Search(context.Background(), adapters.SearchRequest{BotID: "b", Query: "q"})
if err == nil {
t.Fatal("expected error from nil service")
}
}

func TestBuiltinProviderCRUDErrorsWithNilService(t *testing.T) {
t.Parallel()
p := NewBuiltinProvider(slog.Default(), nil, nil, nil)
Expand Down Expand Up @@ -386,20 +374,19 @@ func TestBuiltinProviderCRUDErrorsWithNilService(t *testing.T) {

func TestNewBuiltinRuntimeFromConfig_DefaultReturnsFileRuntime(t *testing.T) {
t.Parallel()
sentinel := "file-runtime-sentinel"
rt, err := NewBuiltinRuntimeFromConfig(nil, nil, sentinel, nil, nil, defaultTestConfig())
rt, err := NewBuiltinRuntimeFromConfig(nil, nil, nil, nil, defaultTestConfig())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt != sentinel {
t.Fatalf("expected file runtime sentinel, got %v", rt)
if rt.Mode() != string(ModeOff) {
t.Fatalf("expected file runtime in mode off, got %q", rt.Mode())
}
}

func TestNewBuiltinRuntimeFromConfig_DenseErrorPropagates(t *testing.T) {
t.Parallel()
cfg := map[string]any{"memory_mode": "dense"}
_, err := NewBuiltinRuntimeFromConfig(nil, cfg, "fallback", nil, nil, defaultTestConfig())
_, err := NewBuiltinRuntimeFromConfig(nil, cfg, nil, nil, defaultTestConfig())
if err == nil {
t.Fatal("expected error for dense mode without embedding_model_id")
}
Expand All @@ -408,7 +395,7 @@ func TestNewBuiltinRuntimeFromConfig_DenseErrorPropagates(t *testing.T) {
func TestNewBuiltinRuntimeFromConfig_SparseErrorPropagates(t *testing.T) {
t.Parallel()
cfg := map[string]any{"memory_mode": "sparse"}
_, err := NewBuiltinRuntimeFromConfig(nil, cfg, "fallback", nil, nil, defaultTestConfig())
_, err := NewBuiltinRuntimeFromConfig(nil, cfg, nil, nil, defaultTestConfig())
if err == nil {
t.Fatal("expected error for sparse mode without encoder base URL")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/memory/adapters/builtin/dense_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func float64sToFloat32s(in []float64) []float32 {
return out
}

// --- memoryRuntime interface ---
// --- Runtime interface ---

func (r *denseRuntime) Add(ctx context.Context, req adapters.AddRequest) (adapters.SearchResponse, error) {
botID, err := runtimeBotID(req.BotID, req.Filters)
Expand Down
6 changes: 3 additions & 3 deletions internal/memory/adapters/builtin/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ const (
ModeDense BuiltinMemoryMode = "dense"
)

// NewBuiltinRuntimeFromConfig returns the appropriate memoryRuntime based on
// NewBuiltinRuntimeFromConfig returns the appropriate Runtime based on
// the provider's persisted config (memory_mode field). Returns the file
// runtime for "off" or unknown modes. Returns an error if a sparse or dense
// runtime was explicitly requested but failed to initialise, so that callers
// can surface configuration problems rather than silently degrading.
func NewBuiltinRuntimeFromConfig(_ *slog.Logger, providerConfig map[string]any, fileRuntime any, store *storefs.Service, queries dbstore.Queries, cfg config.Config) (any, error) {
func NewBuiltinRuntimeFromConfig(_ *slog.Logger, providerConfig map[string]any, store *storefs.Service, queries dbstore.Queries, cfg config.Config) (Runtime, error) {
mode := BuiltinMemoryMode(strings.TrimSpace(adapters.StringFromConfig(providerConfig, "memory_mode")))

switch mode {
Expand Down Expand Up @@ -54,7 +54,7 @@ func NewBuiltinRuntimeFromConfig(_ *slog.Logger, providerConfig map[string]any,
return newDenseRuntime(providerConfig, queries, cfg, store)

default:
return fileRuntime, nil
return NewFileRuntime(store), nil
}
}

Expand Down
20 changes: 6 additions & 14 deletions internal/memory/adapters/builtin/file_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,20 @@ import (
storefs "github.com/memohai/memoh/internal/memory/storefs"
)

type fileMemoryStore interface {
PersistMemories(ctx context.Context, botID string, items []storefs.MemoryItem, filters map[string]any) error
ReadAllMemoryFiles(ctx context.Context, botID string) ([]storefs.MemoryItem, error)
RemoveMemories(ctx context.Context, botID string, ids []string) error
RemoveAllMemories(ctx context.Context, botID string) error
RebuildFiles(ctx context.Context, botID string, items []storefs.MemoryItem, filters map[string]any) error
ArchiveAndRebuildFiles(ctx context.Context, botID string, active []storefs.MemoryItem, archived []storefs.MemoryItem, filters map[string]any) error
SyncOverview(ctx context.Context, botID string) error
CountMemoryFiles(ctx context.Context, botID string) (int, error)
}

// fileRuntime implements the built-in file-backed memory runtime. Markdown files
// remain the source of truth, with no derived vector index.
type fileRuntime struct {
store fileMemoryStore
store memoryStore
}

func NewFileRuntime(store *storefs.Service) *fileRuntime {
// NewFileRuntime returns the file-only Runtime used when the builtin provider
// runs with memory_mode "off": markdown files are served directly without any
// derived vector index.
func NewFileRuntime(store *storefs.Service) Runtime {
return newFileRuntime(store)
}

func newFileRuntime(store fileMemoryStore) *fileRuntime {
func newFileRuntime(store memoryStore) *fileRuntime {
if store == nil {
return nil
}
Expand Down
6 changes: 3 additions & 3 deletions internal/memory/adapters/builtin/formation.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type formationResult struct {
}

// runFormation executes the Extract -> candidate retrieval -> Decide -> apply pipeline.
func runFormation(ctx context.Context, logger *slog.Logger, llm adapters.LLM, runtime memoryRuntime, req adapters.AfterChatRequest) formationResult {
func runFormation(ctx context.Context, logger *slog.Logger, llm adapters.LLM, runtime Runtime, req adapters.AfterChatRequest) formationResult {
ctx, cancel := context.WithTimeout(ctx, formationTimeout)
defer cancel()

Expand Down Expand Up @@ -77,7 +77,7 @@ func runFormation(ctx context.Context, logger *slog.Logger, llm adapters.LLM, ru
}

// gatherCandidates collects existing memories relevant to the extracted facts.
func gatherCandidates(ctx context.Context, logger *slog.Logger, runtime memoryRuntime, botID string, facts []string) []adapters.CandidateMemory {
func gatherCandidates(ctx context.Context, logger *slog.Logger, runtime Runtime, botID string, facts []string) []adapters.CandidateMemory {
seen := make(map[string]struct{})
candidates := make([]adapters.CandidateMemory, 0, candidateSearchLimit)

Expand Down Expand Up @@ -157,7 +157,7 @@ func gatherCandidates(ctx context.Context, logger *slog.Logger, runtime memoryRu
}

// applyActions executes the decided CRUD actions against the runtime.
func applyActions(ctx context.Context, logger *slog.Logger, runtime memoryRuntime, botID string, actions []adapters.DecisionAction, filters map[string]any, metadata map[string]any, result *formationResult) {
func applyActions(ctx context.Context, logger *slog.Logger, runtime Runtime, botID string, actions []adapters.DecisionAction, filters map[string]any, metadata map[string]any, result *formationResult) {
deleted := make(map[string]struct{})
updated := make(map[string]struct{})

Expand Down
4 changes: 0 additions & 4 deletions internal/memory/adapters/builtin/formation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,6 @@ func (f *fakeLLM) Compact(_ context.Context, req adapters.CompactRequest) (adapt
return adapters.CompactResponse{Facts: f.compactFacts}, f.compactErr
}

func (*fakeLLM) DetectLanguage(context.Context, string) (string, error) {
return "", nil
}

func TestFormationExtractAndAdd(t *testing.T) {
t.Parallel()
encoder := &fakeSparseEncoder{}
Expand Down
13 changes: 13 additions & 0 deletions internal/memory/adapters/builtin/shared.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package builtin

import (
"context"
"errors"
"fmt"
"strconv"
Expand All @@ -14,6 +15,18 @@ import (
storefs "github.com/memohai/memoh/internal/memory/storefs"
)

// memoryStore is the markdown file store consumed by the builtin runtimes.
type memoryStore interface {
PersistMemories(ctx context.Context, botID string, items []storefs.MemoryItem, filters map[string]any) error
ReadAllMemoryFiles(ctx context.Context, botID string) ([]storefs.MemoryItem, error)
RemoveMemories(ctx context.Context, botID string, ids []string) error
RemoveAllMemories(ctx context.Context, botID string) error
RebuildFiles(ctx context.Context, botID string, items []storefs.MemoryItem, filters map[string]any) error
ArchiveAndRebuildFiles(ctx context.Context, botID string, active []storefs.MemoryItem, archived []storefs.MemoryItem, filters map[string]any) error
SyncOverview(ctx context.Context, botID string) error
CountMemoryFiles(ctx context.Context, botID string) (int, error)
}

func canonicalStoreItem(item storefs.MemoryItem) storefs.MemoryItem {
item.ID = strings.TrimSpace(item.ID)
item.Memory = strings.TrimSpace(item.Memory)
Expand Down
15 changes: 2 additions & 13 deletions internal/memory/adapters/builtin/sparse_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,12 @@ type sparseIndex interface {
DeleteByBotID(ctx context.Context, botID string) error
}

type sparseMemoryStore interface {
PersistMemories(ctx context.Context, botID string, items []storefs.MemoryItem, filters map[string]any) error
ReadAllMemoryFiles(ctx context.Context, botID string) ([]storefs.MemoryItem, error)
RemoveMemories(ctx context.Context, botID string, ids []string) error
RemoveAllMemories(ctx context.Context, botID string) error
RebuildFiles(ctx context.Context, botID string, items []storefs.MemoryItem, filters map[string]any) error
ArchiveAndRebuildFiles(ctx context.Context, botID string, active []storefs.MemoryItem, archived []storefs.MemoryItem, filters map[string]any) error
SyncOverview(ctx context.Context, botID string) error
CountMemoryFiles(ctx context.Context, botID string) (int, error)
}

// sparseRuntime implements memoryRuntime with markdown files as the source of
// sparseRuntime implements Runtime with markdown files as the source of
// truth and Qdrant as a derived sparse index used for retrieval.
type sparseRuntime struct {
qdrant sparseIndex
encoder sparseEncoder
store sparseMemoryStore
store memoryStore
}

const (
Expand Down
1 change: 0 additions & 1 deletion internal/memory/adapters/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ type LLM interface {
Extract(ctx context.Context, req ExtractRequest) (ExtractResponse, error)
Decide(ctx context.Context, req DecideRequest) (DecideResponse, error)
Compact(ctx context.Context, req CompactRequest) (CompactResponse, error)
DetectLanguage(ctx context.Context, text string) (string, error)
}

type Message struct {
Expand Down
4 changes: 0 additions & 4 deletions internal/memory/compactplan/compactplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ func (r *recordingLLM) Compact(_ context.Context, req adapters.CompactRequest) (
return adapters.CompactResponse{Facts: facts}, nil
}

func (*recordingLLM) DetectLanguage(context.Context, string) (string, error) {
return "", nil
}

func TestBuildRequiresBotID(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading