feat(memory): add Spaces support, platform/group annotation, and bot name to Nowledge Mem

- Use Nowledge Mem Spaces for per-bot memory isolation (space name: memoh:{botID})
- Auto-ensure space on first use with sync.Map cache
- Add platform/conversation context header to stored text: (Telegram 群组「开发讨论」)
- Replace [我] with bot's actual display name [小助手]
- Thread ConversationType, ConversationName, Platform, BotName through AfterChatRequest
- Add resolveBotDisplayName to resolver for DB lookup
This commit is contained in:
Menci
2026-04-12 15:15:14 +08:00
parent fefbc155c6
commit df1e1fc917
5 changed files with 233 additions and 20 deletions
+22 -5
View File
@@ -60,19 +60,25 @@ Nowledge Mem 是单用户个人知识库,没有 `user_id` 或 `group_id` 概
### 存储文本格式
每条记忆对应一轮对话(用户消息 + bot 回复):
每条记忆对应一轮对话(用户消息 + bot 回复),头部标注来源上下文
```
(Telegram 群组「开发讨论」)
[张三] 我最近在用 Rust 重写后端
[] 很好的选择,Rust 的性能和安全性都很出色
[小助手] 很好的选择,Rust 的性能和安全性都很出色
```
- **头部标注**`({Platform} {会话类型}「{群组名}」)`
- Platform:从 `CurrentChannel` 取值(telegram / feishu / discord 等),首字母大写
- 会话类型映射:`group``群组``private``私聊``thread``话题`
- 群组名:有则加 `「...」`,无则省略(私聊一般没有群组名)
- 示例:`(Telegram 私聊)``(Feishu 群组「开发讨论」)`
- **用户消息**`[{display-name}] {消息内容}`
- display-name 从 YAML front-matter header 中解析(Memoh 的 user_header.go 在每条用户消息中嵌入了 `display-name` 字段)
- 回退链:YAML header → AfterChatRequest.DisplayName → `"用户"`
- **Bot 消息**`[] {消息内容}`
- LLM 读到 `[我]` 时自然理解为"这是我之前说过的话"
- `我` 不是命名实体,不会在知识图谱中产生误导节点
- **Bot 消息**`[{bot-display-name}] {消息内容}`
- 使用 bot 的 display name(从 bots 表的 `display_name` 字段取)
- Nowledge Mem 的实体抽取会将 bot 名识别为 Person 实体
### 为什么不用 `(@username)` 双标识
@@ -87,6 +93,17 @@ AfterChatRequest 中只有 `DisplayName`(人类可读名)和内部 UUID(Us
不需要在查询时拼接发言人信息。
### Spaces 隔离
利用 Nowledge Mem 的 Spaces 功能实现 per-bot 记忆隔离:
- 每个 bot 自动映射到一个 Space,名称为 `memoh:{botID}`botID 是稳定的 UUID
- 首次使用时自动 ensure`GET /spaces` 查找 → 未找到则 `POST /spaces` 创建)
- `sync.Map` 缓存 `botID → spaceID` 映射,避免重复 API 调用
- 所有 `POST /memories``POST /memories/search` 调用带 `space_id` 参数
- 不同 bot 的记忆完全隔离,搜索不互相干扰
- Entity graph 保持全局(Nowledge Mem 设计如此),跨 bot 的实体关联仍可用
### 上下文注入格式
与现有 provider 一致的 `<memory-context>` XML 格式:
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/memohai/memoh/internal/conversation"
"github.com/memohai/memoh/internal/db"
memprovider "github.com/memohai/memoh/internal/memory/adapters"
)
@@ -77,11 +78,33 @@ func (r *Resolver) storeMemory(ctx context.Context, req conversation.ChatRequest
ChannelIdentityID: strings.TrimSpace(req.SourceChannelIdentityID),
DisplayName: r.resolveDisplayName(ctx, req),
TimezoneLocation: tzLoc,
ConversationType: strings.TrimSpace(req.ConversationType),
ConversationName: strings.TrimSpace(req.ConversationName),
Platform: strings.TrimSpace(req.CurrentChannel),
BotName: r.resolveBotDisplayName(ctx, botID),
}); err != nil {
r.logger.Warn("memory provider OnAfterChat failed", slog.String("bot_id", botID), slog.Any("error", err))
}
}
func (r *Resolver) resolveBotDisplayName(ctx context.Context, botID string) string {
if r.queries == nil {
return ""
}
botUUID, err := db.ParseUUID(botID)
if err != nil {
return ""
}
row, err := r.queries.GetBotByID(ctx, botUUID)
if err != nil {
return ""
}
if row.DisplayName.Valid {
return strings.TrimSpace(row.DisplayName.String)
}
return ""
}
func toProviderMessages(messages []conversation.ModelMessage) []memprovider.Message {
out := make([]memprovider.Message, 0, len(messages))
for _, msg := range messages {
+67 -2
View File
@@ -42,6 +42,7 @@ func newNmemClient(config map[string]any) (*nmemClient, error) {
type nmemMemoryCreateRequest struct {
Content string `json:"content"`
Source string `json:"source,omitempty"`
SpaceID string `json:"space_id,omitempty"`
}
type nmemMemoryUpdateRequest struct {
@@ -52,6 +53,7 @@ type nmemSearchRequest struct {
Query string `json:"query"`
Limit int `json:"limit,omitempty"`
IncludeEntities bool `json:"include_entities,omitempty"`
SpaceID string `json:"space_id,omitempty"`
}
type nmemMemory struct {
@@ -80,25 +82,43 @@ type nmemDeleteResponse struct {
DeletedEntities int `json:"deleted_entities,omitempty"`
}
// --- Space types ---
type nmemSpaceCreateRequest struct {
Name string `json:"name"`
}
type nmemSpace struct {
ID string `json:"id"`
Name string `json:"name"`
}
type nmemSpacesResponse struct {
Enabled bool `json:"enabled"`
Spaces []nmemSpace `json:"spaces"`
}
// --- Client methods ---
func (c *nmemClient) addMemory(ctx context.Context, content, source string) (*nmemMemory, error) {
func (c *nmemClient) addMemory(ctx context.Context, content, source, spaceID string) (*nmemMemory, error) {
var result nmemMemory
if err := c.doJSON(ctx, http.MethodPost, "/memories", nmemMemoryCreateRequest{
Content: content,
Source: source,
SpaceID: spaceID,
}, &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) {
func (c *nmemClient) searchMemories(ctx context.Context, query string, limit int, spaceID string) ([]nmemSearchResult, error) {
var result nmemSearchResponse
if err := c.doJSON(ctx, http.MethodPost, "/memories/search", nmemSearchRequest{
Query: query,
Limit: limit,
IncludeEntities: true,
SpaceID: spaceID,
}, &result); err != nil {
return nil, fmt.Errorf("nowledgemem search: %w", err)
}
@@ -123,6 +143,51 @@ func (c *nmemClient) deleteMemory(ctx context.Context, id string) error {
return nil
}
func (c *nmemClient) listSpaces(ctx context.Context) ([]nmemSpace, error) {
var result nmemSpacesResponse
if err := c.doJSON(ctx, http.MethodGet, "/spaces", nil, &result); err != nil {
return nil, fmt.Errorf("nowledgemem list spaces: %w", err)
}
return result.Spaces, nil
}
func (c *nmemClient) createSpace(ctx context.Context, name string) (*nmemSpace, error) {
var result nmemSpacesResponse
if err := c.doJSON(ctx, http.MethodPost, "/spaces", nmemSpaceCreateRequest{
Name: name,
}, &result); err != nil {
return nil, fmt.Errorf("nowledgemem create space: %w", err)
}
for _, s := range result.Spaces {
if s.Name == name {
return &s, nil
}
}
if len(result.Spaces) > 0 {
last := result.Spaces[len(result.Spaces)-1]
return &last, nil
}
return nil, fmt.Errorf("nowledgemem create space: space %q not found in response", name)
}
// ensureSpace finds an existing space by name or creates it. Returns the space ID.
func (c *nmemClient) ensureSpace(ctx context.Context, name string) (string, error) {
spaces, err := c.listSpaces(ctx)
if err != nil {
return "", err
}
for _, s := range spaces {
if s.Name == name {
return s.ID, nil
}
}
created, err := c.createSpace(ctx, name)
if err != nil {
return "", err
}
return created.ID, nil
}
// --- HTTP helper ---
func (c *nmemClient) doJSON(ctx context.Context, method, urlPath string, body any, result any) error {
@@ -3,8 +3,11 @@ package nowledgemem
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"unicode"
"github.com/memohai/memoh/internal/mcp"
adapters "github.com/memohai/memoh/internal/memory/adapters"
@@ -23,8 +26,9 @@ const (
// NowledgeMemProvider implements adapters.Provider by delegating to a local Nowledge Mem instance.
type NowledgeMemProvider struct {
client *nmemClient
logger *slog.Logger
client *nmemClient
logger *slog.Logger
spaceIDs sync.Map // botID → spaceID cache
}
func NewNowledgeMemProvider(log *slog.Logger, config map[string]any) (*NowledgeMemProvider, error) {
@@ -50,7 +54,12 @@ func (p *NowledgeMemProvider) OnBeforeChat(ctx context.Context, req adapters.Bef
if query == "" {
return nil, nil
}
results, err := p.client.searchMemories(ctx, query, nmemContextMaxItems)
spaceID, err := p.resolveSpaceID(ctx, req.BotID)
if err != nil {
p.logger.Warn("nowledgemem resolve space failed", slog.Any("error", err))
return nil, nil
}
results, err := p.client.searchMemories(ctx, query, nmemContextMaxItems, spaceID)
if err != nil {
p.logger.Warn("nowledgemem search for context failed", slog.Any("error", err))
return nil, nil
@@ -91,11 +100,16 @@ func (p *NowledgeMemProvider) OnAfterChat(ctx context.Context, req adapters.Afte
if len(req.Messages) == 0 {
return nil
}
content := formatConversation(req.Messages, req.DisplayName)
content := formatConversation(req.Messages, req.DisplayName, req.BotName, req.Platform, req.ConversationType, req.ConversationName)
if content == "" {
return nil
}
if _, err := p.client.addMemory(ctx, content, nmemSource); err != nil {
spaceID, err := p.resolveSpaceID(ctx, req.BotID)
if err != nil {
p.logger.Warn("nowledgemem resolve space failed", slog.Any("error", err))
return nil
}
if _, err := p.client.addMemory(ctx, content, nmemSource, spaceID); err != nil {
p.logger.Warn("nowledgemem store memory failed", slog.Any("error", err))
}
return nil
@@ -126,7 +140,7 @@ func (*NowledgeMemProvider) ListTools(_ context.Context, _ mcp.ToolSessionContex
}, nil
}
func (p *NowledgeMemProvider) CallTool(ctx context.Context, _ mcp.ToolSessionContext, toolName string, arguments map[string]any) (map[string]any, error) {
func (p *NowledgeMemProvider) CallTool(ctx context.Context, session mcp.ToolSessionContext, toolName string, arguments map[string]any) (map[string]any, error) {
if toolName != nmemToolSearchMemory {
return nil, mcp.ErrToolNotFound
}
@@ -147,7 +161,12 @@ func (p *NowledgeMemProvider) CallTool(ctx context.Context, _ mcp.ToolSessionCon
limit = nmemMaxLimit
}
results, err := p.client.searchMemories(ctx, query, limit)
spaceID, err := p.resolveSpaceID(ctx, session.BotID)
if err != nil {
return mcp.BuildToolErrorResult(fmt.Sprintf("resolve space failed: %v", err)), nil
}
results, err := p.client.searchMemories(ctx, query, limit, spaceID)
if err != nil {
return mcp.BuildToolErrorResult("memory search failed"), nil
}
@@ -171,12 +190,16 @@ func (p *NowledgeMemProvider) CallTool(ctx context.Context, _ mcp.ToolSessionCon
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, "")
text = formatConversation(req.Messages, "", "", "", "", "")
}
if text == "" {
return adapters.SearchResponse{}, errors.New("message is required")
}
mem, err := p.client.addMemory(ctx, text, nmemSource)
spaceID, err := p.resolveSpaceID(ctx, req.BotID)
if err != nil {
return adapters.SearchResponse{}, fmt.Errorf("resolve space: %w", err)
}
mem, err := p.client.addMemory(ctx, text, nmemSource, spaceID)
if err != nil {
return adapters.SearchResponse{}, err
}
@@ -190,7 +213,11 @@ func (p *NowledgeMemProvider) Search(ctx context.Context, req adapters.SearchReq
} else if limit > nmemMaxLimit {
limit = nmemMaxLimit
}
results, err := p.client.searchMemories(ctx, req.Query, limit)
spaceID, err := p.resolveSpaceID(ctx, req.BotID)
if err != nil {
return adapters.SearchResponse{}, fmt.Errorf("resolve space: %w", err)
}
results, err := p.client.searchMemories(ctx, req.Query, limit, spaceID)
if err != nil {
return adapters.SearchResponse{}, err
}
@@ -251,11 +278,40 @@ func (*NowledgeMemProvider) Usage(_ context.Context, _ map[string]any) (adapters
// --- Helpers ---
const nmemSpacePrefix = "memoh:"
// resolveSpaceID returns the Nowledge Mem space ID for a given bot.
// It caches the mapping in a sync.Map to avoid repeated API calls.
func (p *NowledgeMemProvider) resolveSpaceID(ctx context.Context, botID string) (string, error) {
botID = strings.TrimSpace(botID)
if botID == "" {
return "", errors.New("botID is required for space resolution")
}
if cached, ok := p.spaceIDs.Load(botID); ok {
return cached.(string), nil
}
spaceName := nmemSpacePrefix + botID
spaceID, err := p.client.ensureSpace(ctx, spaceName)
if err != nil {
return "", fmt.Errorf("ensure space %q: %w", spaceName, err)
}
p.spaceIDs.Store(botID, spaceID)
return spaceID, nil
}
// 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 {
// front-matter header; bot messages are tagged with [botName].
// A header line annotates platform and conversation context.
func formatConversation(messages []adapters.Message, fallbackDisplayName, botName, platform, convType, convName string) string {
var sb strings.Builder
// Header annotation: (Platform convType「convName」)
header := buildContextHeader(platform, convType, convName)
if header != "" {
sb.WriteString(header)
}
for _, msg := range messages {
text := strings.TrimSpace(msg.Content)
if text == "" {
@@ -288,13 +344,61 @@ func formatConversation(messages []adapters.Message, fallbackDisplayName string)
if sb.Len() > 0 {
sb.WriteByte('\n')
}
sb.WriteString("[我] ")
sb.WriteString("[")
sb.WriteString(botName)
sb.WriteString("] ")
sb.WriteString(text)
}
}
return sb.String()
}
// buildContextHeader produces the platform/conversation annotation line.
// Format: (Platform convType「convName」)
func buildContextHeader(platform, convType, convName string) string {
platform = strings.TrimSpace(platform)
convType = strings.TrimSpace(convType)
convName = strings.TrimSpace(convName)
platformDisplay := capitalizeFirst(platform)
convTypeDisplay := mapConversationType(convType)
var sb strings.Builder
sb.WriteString("(")
sb.WriteString(platformDisplay)
sb.WriteString(" ")
sb.WriteString(convTypeDisplay)
if convName != "" {
sb.WriteString("「")
sb.WriteString(convName)
sb.WriteString("」")
}
sb.WriteString(")")
return sb.String()
}
func mapConversationType(t string) string {
switch t {
case "group":
return "群组"
case "private":
return "私聊"
case "thread":
return "话题"
default:
return t
}
}
func capitalizeFirst(s string) string {
if s == "" {
return s
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
// 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.
+4
View File
@@ -25,6 +25,10 @@ type AfterChatRequest struct {
ChannelIdentityID string
DisplayName string
TimezoneLocation *time.Location
ConversationType string // "private", "group", "thread"
ConversationName string // group name (set for group/thread conversations)
Platform string // "telegram", "feishu", "discord", etc.
BotName string // bot's display name
}
// LLM is the interface for LLM operations needed by memory service.