mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
+22
-5
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user