diff --git a/docs/nowledge-mem.md b/docs/nowledge-mem.md index 8b8c2f80..2d0b8e5e 100644 --- a/docs/nowledge-mem.md +++ b/docs/nowledge-mem.md @@ -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 一致的 `` XML 格式: diff --git a/internal/conversation/flow/resolver_memory.go b/internal/conversation/flow/resolver_memory.go index 8e833b8e..001a84a6 100644 --- a/internal/conversation/flow/resolver_memory.go +++ b/internal/conversation/flow/resolver_memory.go @@ -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 { diff --git a/internal/memory/adapters/nowledgemem/client.go b/internal/memory/adapters/nowledgemem/client.go index 365e1438..17fe609b 100644 --- a/internal/memory/adapters/nowledgemem/client.go +++ b/internal/memory/adapters/nowledgemem/client.go @@ -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 { diff --git a/internal/memory/adapters/nowledgemem/nowledgemem.go b/internal/memory/adapters/nowledgemem/nowledgemem.go index 5d63c901..837e3560 100644 --- a/internal/memory/adapters/nowledgemem/nowledgemem.go +++ b/internal/memory/adapters/nowledgemem/nowledgemem.go @@ -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. diff --git a/internal/memory/adapters/types.go b/internal/memory/adapters/types.go index 2e003d78..3e0d4d12 100644 --- a/internal/memory/adapters/types.go +++ b/internal/memory/adapters/types.go @@ -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.