Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0b37a87
Add .worktrees to gitignore
reyortiz3 Apr 21, 2026
7bd9816
Add memory package types
reyortiz3 Apr 21, 2026
3bf0acf
Add shared long-term memory server core package
reyortiz3 Apr 22, 2026
1a30338
Add thv-memory MCP server binary
reyortiz3 Apr 22, 2026
a7fa58c
Document shared memory server design and activation strategy
reyortiz3 Apr 22, 2026
40c641d
Merge branch 'main' into feature/memory-server-core
reyortiz3 Apr 22, 2026
f8e4c92
Add static resource entries with management REST API and MCP Resource…
reyortiz3 Apr 24, 2026
cea3859
Add recruiter scenario demo for memory server
reyortiz3 Apr 24, 2026
51e1249
Fix demo Makefile to build thv-memory binary directly
reyortiz3 Apr 24, 2026
b605787
Fix resource source constraint, add tags to remember tool, fix demo p…
reyortiz3 Apr 24, 2026
6db261a
Add Claude Code agent sessions to recruiter demo
reyortiz3 Apr 24, 2026
6fa8225
Rewrite demo prompts as natural recruiter chat
reyortiz3 Apr 26, 2026
51054d5
Make demo sessions interactive — print prompt and wait
reyortiz3 Apr 28, 2026
c31dafc
Clean up Claude-written local files between demo runs
reyortiz3 Apr 28, 2026
ecab88f
Add Reveal.js presentation for memory architecture demo
reyortiz3 Apr 28, 2026
e37b1da
Expand slides: research, compare table, references, fix layout
reyortiz3 Apr 28, 2026
e96b368
Fix slide overflow — content no longer exceeds viewport
reyortiz3 Apr 29, 2026
4189ec0
Enable per-slide scrolling in Reveal.js presentation
reyortiz3 Apr 29, 2026
0ba0935
Improve architecture slide: pluggable backends + clearer MCP box
reyortiz3 Apr 29, 2026
f75e65c
Title slide: surface 'manage' alongside write and search
reyortiz3 Apr 29, 2026
f4a9242
Add hierarchical memory roadmap slide
reyortiz3 Apr 30, 2026
74136ad
Add five design tensions slide from arXiv 2603.07670
reyortiz3 Apr 30, 2026
db4baa1
Rename title slide subtitle to emphasize cross-session persistence
reyortiz3 Apr 30, 2026
9bb592f
Generalize agent references from Claude Code to any MCP agent
reyortiz3 Apr 30, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ coverage*
crd-helm-wrapper
cmd/vmcp/__debug_bin*
/vmcp
.worktrees/
112 changes: 112 additions & 0 deletions cmd/thv-memory/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package main is the entry point for the ToolHive memory MCP server.
package main

import (
"fmt"
"os"

"gopkg.in/yaml.v3"
)

const (
providerOllama = "ollama"
)

// Config is the memory server configuration, loaded from memory-server.yaml.
type Config struct {
Storage StorageConfig `yaml:"storage"`
Vector VectorConfig `yaml:"vector"`
Embedder EmbedderConfig `yaml:"embedder"`
Server ServerConfig `yaml:"server"`
}

// StorageConfig configures the Store backend.
type StorageConfig struct {
Provider string `yaml:"provider"` // sqlite (default)
DSN string `yaml:"dsn"`
}

// VectorConfig configures the VectorStore backend.
type VectorConfig struct {
Provider string `yaml:"provider"` // sqlite-vec (default) | qdrant | pgvector
URL string `yaml:"url"`
}

// EmbedderConfig configures the Embedder backend.
type EmbedderConfig struct {
Provider string `yaml:"provider"` // ollama (default) | openai
URL string `yaml:"url"`
Model string `yaml:"model"`
}

// ServerConfig configures the MCP server itself.
type ServerConfig struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Host string `yaml:"host"` // default 0.0.0.0
Port int `yaml:"port"` // default 8080
LifecycleHours int `yaml:"lifecycle_interval_hours"` // default 24
}

// LoadConfig reads and validates config from path. The path is operator-supplied
// and expected to be a trusted config file location.
func LoadConfig(path string) (*Config, error) {
// G304: path is an operator-supplied config file, not user input.
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
applyStorageDefaults(&cfg)
applyEmbedderDefaults(&cfg)
applyServerDefaults(&cfg)
return &cfg, nil
}

func applyStorageDefaults(cfg *Config) {
if cfg.Storage.Provider == "" {
cfg.Storage.Provider = "sqlite"
}
if cfg.Storage.DSN == "" && cfg.Storage.Provider == "sqlite" {
cfg.Storage.DSN = "/data/memory.db"
}
if cfg.Vector.Provider == "" {
cfg.Vector.Provider = "sqlite-vec"
}
}

func applyEmbedderDefaults(cfg *Config) {
if cfg.Embedder.Provider == "" {
cfg.Embedder.Provider = providerOllama
}
if cfg.Embedder.Model == "" {
cfg.Embedder.Model = "nomic-embed-text"
}
if cfg.Embedder.URL == "" && cfg.Embedder.Provider == providerOllama {
cfg.Embedder.URL = "http://localhost:11434"
}
}

func applyServerDefaults(cfg *Config) {
if cfg.Server.Name == "" {
cfg.Server.Name = "toolhive-memory"
}
if cfg.Server.Version == "" {
cfg.Server.Version = "0.1.0"
}
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.Server.Port <= 0 {
cfg.Server.Port = 8080
}
if cfg.Server.LifecycleHours <= 0 {
cfg.Server.LifecycleHours = 24
}
}
114 changes: 114 additions & 0 deletions cmd/thv-memory/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package main_test

import (
"context"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"

"github.com/stacklok/toolhive/pkg/memory"
memorysqlite "github.com/stacklok/toolhive/pkg/memory/sqlite"
)

// fakeEmbedder returns a deterministic embedding for testing without a real model server.
type fakeEmbedder struct{}

func (*fakeEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
v := []float32{0, 0, 0}
for i, c := range text {
if i >= 3 {
break
}
v[i] = float32(c) / 128.0
}
return v, nil
}

func (*fakeEmbedder) Dimensions() int { return 3 }

func TestIntegration_RememberSearchForget(t *testing.T) {
t.Parallel()
dir := t.TempDir()
resolved, _ := filepath.EvalSymlinks(dir)
db, err := memorysqlite.Open(context.Background(), filepath.Join(resolved, "test.db"))
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })

store := memorysqlite.NewStore(db)
vectors := memorysqlite.NewVectorStore(db)
svc, err := memory.NewService(store, vectors, &fakeEmbedder{}, zaptest.NewLogger(t))
require.NoError(t, err)

ctx := context.Background()

r, err := svc.Remember(ctx, memory.RememberInput{
Content: "deploy to us-east-1",
Type: memory.TypeSemantic,
Author: memory.AuthorHuman,
})
require.NoError(t, err)
require.NotEmpty(t, r.MemoryID)
require.Empty(t, r.Conflicts)

results, err := svc.Search(ctx, "deploy to us-east-1", nil, 5)
require.NoError(t, err)
require.NotEmpty(t, results)
require.Equal(t, "deploy to us-east-1", results[0].Entry.Content)

entry, err := store.Get(ctx, r.MemoryID)
require.NoError(t, err)
require.Equal(t, 1, entry.AccessCount)

require.NoError(t, store.Delete(ctx, r.MemoryID))
_, err = store.Get(ctx, r.MemoryID)
require.ErrorIs(t, err, memory.ErrNotFound)
}

func TestIntegration_ConflictDetection(t *testing.T) {
t.Parallel()
dir := t.TempDir()
resolved, _ := filepath.EvalSymlinks(dir)
db, err := memorysqlite.Open(context.Background(), filepath.Join(resolved, "test2.db"))
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })

store := memorysqlite.NewStore(db)
vectors := memorysqlite.NewVectorStore(db)
svc, err := memory.NewService(store, vectors, &fakeEmbedder{}, zaptest.NewLogger(t))
require.NoError(t, err)

ctx := context.Background()

r1, err := svc.Remember(ctx, memory.RememberInput{
Content: "auth port 8080",
Type: memory.TypeSemantic,
Author: memory.AuthorHuman,
})
require.NoError(t, err)
require.NotEmpty(t, r1.MemoryID)

// fakeEmbedder hashes first 3 chars — "aut" maps to same vector for both,
// so "auth port 9090" will have cosine similarity 1.0 with "auth port 8080".
r2, err := svc.Remember(ctx, memory.RememberInput{
Content: "auth port 9090",
Type: memory.TypeSemantic,
Author: memory.AuthorAgent,
})
require.NoError(t, err)
require.Empty(t, r2.MemoryID)
require.NotEmpty(t, r2.Conflicts)

r3, err := svc.Remember(ctx, memory.RememberInput{
Content: "auth port 9090",
Type: memory.TypeSemantic,
Author: memory.AuthorHuman,
Force: true,
})
require.NoError(t, err)
require.NotEmpty(t, r3.MemoryID)
}
84 changes: 84 additions & 0 deletions cmd/thv-memory/lifecycle/job.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package lifecycle provides the background maintenance job for memory entries.
package lifecycle

import (
"context"
"time"

"go.uber.org/zap"

"github.com/stacklok/toolhive/pkg/memory"
)

// StalenessAuditThreshold is the score above which entries are logged as audit candidates.
const StalenessAuditThreshold = float32(0.8)

// Job runs periodic maintenance on the memory store: expiring TTL'd entries
// and recomputing trust/staleness scores.
type Job struct {
store memory.Store
log *zap.Logger
}

// New creates a new lifecycle Job.
func New(store memory.Store, log *zap.Logger) *Job {
return &Job{store: store, log: log}
}

// Run starts the background job, ticking at the given interval until ctx is cancelled.
func (j *Job) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := j.RunOnce(ctx); err != nil {
j.log.Warn("lifecycle job error", zap.Error(err))
}
}
}
}

// RunOnce executes one maintenance pass: expire TTL'd entries, update scores.
func (j *Job) RunOnce(ctx context.Context) error {
if err := j.expireEntries(ctx); err != nil {
return err
}
return j.recomputeScores(ctx)
}

func (j *Job) expireEntries(ctx context.Context) error {
expired, err := j.store.ListExpired(ctx)
if err != nil {
return err
}
for _, e := range expired {
if err := j.store.Archive(ctx, e.ID, memory.ArchiveReasonExpired, ""); err != nil {
j.log.Warn("failed to archive expired entry", zap.String("id", e.ID), zap.Error(err))
}
}
return nil
}

func (j *Job) recomputeScores(ctx context.Context) error {
entries, err := j.store.ListActive(ctx)
if err != nil {
return err
}
for _, e := range entries {
trust := memory.ComputeTrustScore(e)
staleness := memory.ComputeStalenessScore(e)
if err := j.store.UpdateScores(ctx, e.ID, trust, staleness); err != nil {
j.log.Warn("failed to update scores", zap.String("id", e.ID), zap.Error(err))
}
if staleness >= StalenessAuditThreshold {
j.log.Debug("high staleness entry", zap.String("id", e.ID), zap.Float32("staleness", staleness))
}
}
return nil
}
55 changes: 55 additions & 0 deletions cmd/thv-memory/lifecycle/job_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package lifecycle_test

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"go.uber.org/zap/zaptest"

"github.com/stacklok/toolhive/cmd/thv-memory/lifecycle"
"github.com/stacklok/toolhive/pkg/memory"
"github.com/stacklok/toolhive/pkg/memory/mocks"
)

func TestJob_RunOnce_ExpiresEntries(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
store := mocks.NewMockStore(ctrl)

expired := memory.Entry{
ID: "mem_expired",
CreatedAt: time.Now().Add(-48 * time.Hour),
}
store.EXPECT().ListExpired(gomock.Any()).Return([]memory.Entry{expired}, nil)
store.EXPECT().Archive(gomock.Any(), "mem_expired", memory.ArchiveReasonExpired, "").Return(nil)
store.EXPECT().ListActive(gomock.Any()).Return(nil, nil)

job := lifecycle.New(store, zaptest.NewLogger(t))
err := job.RunOnce(context.Background())
require.NoError(t, err)
}

func TestJob_RunOnce_UpdatesScores(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
store := mocks.NewMockStore(ctrl)

entry := memory.Entry{
ID: "mem_active",
Author: memory.AuthorHuman,
CreatedAt: time.Now(),
}
store.EXPECT().ListExpired(gomock.Any()).Return(nil, nil)
store.EXPECT().ListActive(gomock.Any()).Return([]memory.Entry{entry}, nil)
store.EXPECT().UpdateScores(gomock.Any(), "mem_active", gomock.Any(), gomock.Any()).Return(nil)

job := lifecycle.New(store, zaptest.NewLogger(t))
err := job.RunOnce(context.Background())
require.NoError(t, err)
}
Loading
Loading