feat(memory): add Nowledge Mem provider integration

Add a new memory provider that delegates to a local Nowledge Mem instance
for memory storage, retrieval, and knowledge graph building.

Key design decisions:
- User messages tagged as [DisplayName], bot messages as [我]
- Per-message display name parsed from YAML front-matter headers
- Let Nowledge Mem handle entity extraction and graph building
- 6-way hybrid search (semantic + full-text + entity + community + label + graph)

New files:
- internal/memory/adapters/nowledgemem/client.go (REST API client)
- internal/memory/adapters/nowledgemem/nowledgemem.go (Provider impl)
- docs/nowledge-mem.md (design document with research and decisions)

Modified: types.go, service.go, serve.go (provider registration),
frontend (add-memory-provider.vue, types.gen.ts, i18n locales)
This commit is contained in:
Menci
2026-04-08 10:23:24 +08:00
parent 830c521f11
commit fefbc155c6
10 changed files with 678 additions and 7 deletions
@@ -0,0 +1,172 @@
package nowledgemem
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
adapters "github.com/memohai/memoh/internal/memory/adapters"
)
const (
defaultBaseURL = "http://127.0.0.1:14242"
clientTimeout = 30 * time.Second
)
type nmemClient struct {
baseURL string
httpClient *http.Client
}
func newNmemClient(config map[string]any) (*nmemClient, error) {
baseURL := adapters.StringFromConfig(config, "base_url")
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
return &nmemClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: clientTimeout,
},
}, nil
}
// --- Request/Response types ---
type nmemMemoryCreateRequest struct {
Content string `json:"content"`
Source string `json:"source,omitempty"`
}
type nmemMemoryUpdateRequest struct {
Content string `json:"content"`
}
type nmemSearchRequest struct {
Query string `json:"query"`
Limit int `json:"limit,omitempty"`
IncludeEntities bool `json:"include_entities,omitempty"`
}
type nmemMemory struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Content string `json:"content"`
Source string `json:"source,omitempty"`
Time string `json:"time,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type nmemSearchResult struct {
Memory nmemMemory `json:"memory"`
SimilarityScore float64 `json:"similarity_score"`
RelevanceReason string `json:"relevance_reason,omitempty"`
}
type nmemSearchResponse struct {
Results []nmemSearchResult `json:"results"`
}
type nmemDeleteResponse struct {
Message string `json:"message,omitempty"`
DeletedRelationships int `json:"deleted_relationships,omitempty"`
DeletedEntities int `json:"deleted_entities,omitempty"`
}
// --- Client methods ---
func (c *nmemClient) addMemory(ctx context.Context, content, source string) (*nmemMemory, error) {
var result nmemMemory
if err := c.doJSON(ctx, http.MethodPost, "/memories", nmemMemoryCreateRequest{
Content: content,
Source: source,
}, &result); err != nil {
return nil, fmt.Errorf("nowledgemem add: %w", err)
}
return &result, nil
}
func (c *nmemClient) searchMemories(ctx context.Context, query string, limit int) ([]nmemSearchResult, error) {
var result nmemSearchResponse
if err := c.doJSON(ctx, http.MethodPost, "/memories/search", nmemSearchRequest{
Query: query,
Limit: limit,
IncludeEntities: true,
}, &result); err != nil {
return nil, fmt.Errorf("nowledgemem search: %w", err)
}
return result.Results, nil
}
func (c *nmemClient) updateMemory(ctx context.Context, id, content string) (*nmemMemory, error) {
var result nmemMemory
if err := c.doJSON(ctx, http.MethodPatch, "/memories/"+id, nmemMemoryUpdateRequest{
Content: content,
}, &result); err != nil {
return nil, fmt.Errorf("nowledgemem update: %w", err)
}
return &result, nil
}
func (c *nmemClient) deleteMemory(ctx context.Context, id string) error {
var result nmemDeleteResponse
if err := c.doJSON(ctx, http.MethodDelete, "/memories/"+id, nil, &result); err != nil {
return fmt.Errorf("nowledgemem delete: %w", err)
}
return nil
}
// --- HTTP helper ---
func (c *nmemClient) doJSON(ctx context.Context, method, urlPath string, body any, result any) error {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+urlPath, bodyReader)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("nowledgemem API error %d: %s", resp.StatusCode, truncateBody(respBody))
}
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
}
return nil
}
func truncateBody(b []byte) string {
const maxLen = 300
s := strings.TrimSpace(string(b))
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
@@ -0,0 +1,339 @@
package nowledgemem
import (
"context"
"errors"
"log/slog"
"strings"
"github.com/memohai/memoh/internal/mcp"
adapters "github.com/memohai/memoh/internal/memory/adapters"
)
const (
NowledgeMemType = "nowledgemem"
nmemSource = "memoh"
nmemToolSearchMemory = "search_memory"
nmemDefaultLimit = 10
nmemMaxLimit = 50
nmemContextMaxItems = 8
nmemContextMaxChars = 360
)
// NowledgeMemProvider implements adapters.Provider by delegating to a local Nowledge Mem instance.
type NowledgeMemProvider struct {
client *nmemClient
logger *slog.Logger
}
func NewNowledgeMemProvider(log *slog.Logger, config map[string]any) (*NowledgeMemProvider, error) {
if log == nil {
log = slog.Default()
}
c, err := newNmemClient(config)
if err != nil {
return nil, err
}
return &NowledgeMemProvider{
client: c,
logger: log.With(slog.String("provider", NowledgeMemType)),
}, nil
}
func (*NowledgeMemProvider) Type() string { return NowledgeMemType }
// --- Conversation Hooks ---
func (p *NowledgeMemProvider) OnBeforeChat(ctx context.Context, req adapters.BeforeChatRequest) (*adapters.BeforeChatResult, error) {
query := strings.TrimSpace(req.Query)
if query == "" {
return nil, nil
}
results, err := p.client.searchMemories(ctx, query, nmemContextMaxItems)
if err != nil {
p.logger.Warn("nowledgemem search for context failed", slog.Any("error", err))
return nil, nil
}
if len(results) == 0 {
return nil, nil
}
var sb strings.Builder
sb.WriteString("<memory-context>\nRelevant memory context (use when helpful):\n")
count := 0
for _, sr := range results {
if count >= nmemContextMaxItems {
break
}
text := strings.TrimSpace(sr.Memory.Content)
if text == "" {
continue
}
sb.WriteString("- ")
if t := strings.TrimSpace(sr.Memory.Time); t != "" {
if len(t) > 10 {
t = t[:10]
}
sb.WriteString("[")
sb.WriteString(t)
sb.WriteString("] ")
}
sb.WriteString(adapters.TruncateSnippet(text, nmemContextMaxChars))
sb.WriteString("\n")
count++
}
sb.WriteString("</memory-context>")
return &adapters.BeforeChatResult{ContextText: sb.String()}, nil
}
func (p *NowledgeMemProvider) OnAfterChat(ctx context.Context, req adapters.AfterChatRequest) error {
if len(req.Messages) == 0 {
return nil
}
content := formatConversation(req.Messages, req.DisplayName)
if content == "" {
return nil
}
if _, err := p.client.addMemory(ctx, content, nmemSource); err != nil {
p.logger.Warn("nowledgemem store memory failed", slog.Any("error", err))
}
return nil
}
// --- MCP Tools ---
func (*NowledgeMemProvider) ListTools(_ context.Context, _ mcp.ToolSessionContext) ([]mcp.ToolDescriptor, error) {
return []mcp.ToolDescriptor{
{
Name: nmemToolSearchMemory,
Description: "Search for memories relevant to the current chat",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "The query to search memories",
},
"limit": map[string]any{
"type": "integer",
"description": "Maximum number of memory results",
},
},
"required": []string{"query"},
},
},
}, nil
}
func (p *NowledgeMemProvider) CallTool(ctx context.Context, _ mcp.ToolSessionContext, toolName string, arguments map[string]any) (map[string]any, error) {
if toolName != nmemToolSearchMemory {
return nil, mcp.ErrToolNotFound
}
query := mcp.StringArg(arguments, "query")
if query == "" {
return mcp.BuildToolErrorResult("query is required"), nil
}
limit := nmemDefaultLimit
if value, ok, err := mcp.IntArg(arguments, "limit"); err != nil {
return mcp.BuildToolErrorResult(err.Error()), nil
} else if ok {
limit = value
}
if limit <= 0 {
limit = nmemDefaultLimit
}
if limit > nmemMaxLimit {
limit = nmemMaxLimit
}
results, err := p.client.searchMemories(ctx, query, limit)
if err != nil {
return mcp.BuildToolErrorResult("memory search failed"), nil
}
items := make([]map[string]any, 0, len(results))
for _, sr := range results {
items = append(items, map[string]any{
"id": sr.Memory.ID,
"memory": sr.Memory.Content,
"score": sr.SimilarityScore,
})
}
return mcp.BuildToolSuccessResult(map[string]any{
"query": query,
"total": len(items),
"results": items,
}), nil
}
// --- CRUD ---
func (p *NowledgeMemProvider) Add(ctx context.Context, req adapters.AddRequest) (adapters.SearchResponse, error) {
text := strings.TrimSpace(req.Message)
if text == "" && len(req.Messages) > 0 {
text = formatConversation(req.Messages, "")
}
if text == "" {
return adapters.SearchResponse{}, errors.New("message is required")
}
mem, err := p.client.addMemory(ctx, text, nmemSource)
if err != nil {
return adapters.SearchResponse{}, err
}
return adapters.SearchResponse{Results: []adapters.MemoryItem{nmemToItem(*mem)}}, nil
}
func (p *NowledgeMemProvider) Search(ctx context.Context, req adapters.SearchRequest) (adapters.SearchResponse, error) {
limit := req.Limit
if limit <= 0 {
limit = nmemDefaultLimit
} else if limit > nmemMaxLimit {
limit = nmemMaxLimit
}
results, err := p.client.searchMemories(ctx, req.Query, limit)
if err != nil {
return adapters.SearchResponse{}, err
}
items := make([]adapters.MemoryItem, 0, len(results))
for _, sr := range results {
item := nmemToItem(sr.Memory)
item.Score = sr.SimilarityScore
items = append(items, item)
}
return adapters.SearchResponse{Results: items}, nil
}
func (*NowledgeMemProvider) GetAll(_ context.Context, _ adapters.GetAllRequest) (adapters.SearchResponse, error) {
return adapters.SearchResponse{}, errors.New("getall is not supported by nowledgemem provider")
}
func (p *NowledgeMemProvider) Update(ctx context.Context, req adapters.UpdateRequest) (adapters.MemoryItem, error) {
memoryID := strings.TrimSpace(req.MemoryID)
if memoryID == "" {
return adapters.MemoryItem{}, errors.New("memory_id is required")
}
mem, err := p.client.updateMemory(ctx, memoryID, req.Memory)
if err != nil {
return adapters.MemoryItem{}, err
}
return nmemToItem(*mem), nil
}
func (p *NowledgeMemProvider) Delete(ctx context.Context, memoryID string) (adapters.DeleteResponse, error) {
if err := p.client.deleteMemory(ctx, strings.TrimSpace(memoryID)); err != nil {
return adapters.DeleteResponse{}, err
}
return adapters.DeleteResponse{Message: "Memory deleted successfully"}, nil
}
func (p *NowledgeMemProvider) DeleteBatch(ctx context.Context, memoryIDs []string) (adapters.DeleteResponse, error) {
for _, id := range memoryIDs {
if err := p.client.deleteMemory(ctx, strings.TrimSpace(id)); err != nil {
return adapters.DeleteResponse{}, err
}
}
return adapters.DeleteResponse{Message: "Memories deleted successfully"}, nil
}
func (*NowledgeMemProvider) DeleteAll(_ context.Context, _ adapters.DeleteAllRequest) (adapters.DeleteResponse, error) {
return adapters.DeleteResponse{}, errors.New("deleteall is not supported by nowledgemem provider")
}
// --- Lifecycle ---
func (*NowledgeMemProvider) Compact(_ context.Context, _ map[string]any, _ float64, _ int) (adapters.CompactResult, error) {
return adapters.CompactResult{}, errors.New("compact is not supported by nowledgemem provider")
}
func (*NowledgeMemProvider) Usage(_ context.Context, _ map[string]any) (adapters.UsageResponse, error) {
return adapters.UsageResponse{}, errors.New("usage is not supported by nowledgemem provider")
}
// --- Helpers ---
// formatConversation formats a round of messages into attributed text for storage.
// User messages are tagged with the sender's display name parsed from the YAML
// front-matter header; bot messages are tagged with [我].
func formatConversation(messages []adapters.Message, fallbackDisplayName string) string {
var sb strings.Builder
for _, msg := range messages {
text := strings.TrimSpace(msg.Content)
if text == "" {
continue
}
role := strings.TrimSpace(msg.Role)
switch role {
case "user":
displayName, body := parseDisplayNameFromYAML(text)
if displayName == "" {
displayName = strings.TrimSpace(fallbackDisplayName)
}
if displayName == "" {
displayName = "用户"
}
body = strings.TrimSpace(body)
if body == "" {
continue
}
if sb.Len() > 0 {
sb.WriteByte('\n')
}
sb.WriteString("[")
sb.WriteString(displayName)
sb.WriteString("] ")
sb.WriteString(body)
case "assistant":
if sb.Len() > 0 {
sb.WriteByte('\n')
}
sb.WriteString("[我] ")
sb.WriteString(text)
}
}
return sb.String()
}
// parseDisplayNameFromYAML extracts the display-name field from a YAML
// front-matter header (---\n...\n---\n) and returns the remaining body.
// If no valid header is found, displayName is empty and body is the original text.
func parseDisplayNameFromYAML(content string) (displayName, body string) {
if !strings.HasPrefix(content, "---\n") {
return "", content
}
rest := content[4:] // skip opening "---\n"
endIdx := strings.Index(rest, "\n---\n")
if endIdx < 0 {
return "", content
}
header := rest[:endIdx]
body = strings.TrimSpace(rest[endIdx+5:]) // skip "\n---\n"
for _, line := range strings.Split(header, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "display-name:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "display-name:"))
// Strip surrounding quotes if present.
if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {
val = val[1 : len(val)-1]
}
displayName = strings.TrimSpace(val)
break
}
}
return displayName, body
}
func nmemToItem(m nmemMemory) adapters.MemoryItem {
item := adapters.MemoryItem{
ID: m.ID,
Memory: m.Content,
Metadata: m.Metadata,
}
if m.Time != "" {
item.CreatedAt = m.Time
item.UpdatedAt = m.Time
}
return item
}
+16 -1
View File
@@ -131,6 +131,21 @@ func (*Service) ListMeta(_ context.Context) []ProviderMeta {
},
},
},
{
Provider: string(ProviderNowledgeMem),
DisplayName: "Nowledge Mem",
ConfigSchema: ProviderConfigSchema{
Fields: map[string]ProviderFieldSchema{
"base_url": {
Type: "string",
Title: "Base URL",
Description: "Nowledge Mem API base URL. Defaults to http://127.0.0.1:14242 when empty.",
Required: false,
Example: "http://127.0.0.1:14242",
},
},
},
},
}
}
@@ -331,7 +346,7 @@ func (s *Service) tryEvictAndReinstantiate(id, providerType string, config map[s
func isValidProviderType(t ProviderType) bool {
switch t {
case ProviderBuiltin, ProviderMem0, ProviderOpenViking:
case ProviderBuiltin, ProviderMem0, ProviderOpenViking, ProviderNowledgeMem:
return true
default:
return false
+4 -3
View File
@@ -238,9 +238,10 @@ type MemoryStatusResponse struct {
type ProviderType string
const (
ProviderBuiltin ProviderType = "builtin"
ProviderMem0 ProviderType = "mem0"
ProviderOpenViking ProviderType = "openviking"
ProviderBuiltin ProviderType = "builtin"
ProviderMem0 ProviderType = "mem0"
ProviderOpenViking ProviderType = "openviking"
ProviderNowledgeMem ProviderType = "nowledgemem"
)
type ProviderCreateRequest struct {