From fefbc155c6e017a758c4b62ef94ec80f5e37f93a Mon Sep 17 00:00:00 2001 From: Menci Date: Wed, 8 Apr 2026 10:23:24 +0800 Subject: [PATCH] feat(memory): add Nowledge Mem provider integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/i18n/locales/en.json | 3 +- apps/web/src/i18n/locales/zh.json | 3 +- .../memory/components/add-memory-provider.vue | 3 + cmd/memoh/serve.go | 4 + docs/nowledge-mem.md | 135 +++++++ .../memory/adapters/nowledgemem/client.go | 172 +++++++++ .../adapters/nowledgemem/nowledgemem.go | 339 ++++++++++++++++++ internal/memory/adapters/service.go | 17 +- internal/memory/adapters/types.go | 7 +- packages/sdk/src/types.gen.ts | 2 +- 10 files changed, 678 insertions(+), 7 deletions(-) create mode 100644 docs/nowledge-mem.md create mode 100644 internal/memory/adapters/nowledgemem/client.go create mode 100644 internal/memory/adapters/nowledgemem/nowledgemem.go diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index df51d546..a9bd8aea 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -382,7 +382,8 @@ "providerNames": { "builtin": "Built-in", "mem0": "Mem0", - "openviking": "OpenViking" + "openviking": "OpenViking", + "nowledgemem": "Nowledge Mem" } }, "speech": { diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 9b0fb62d..4939b3d1 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -378,7 +378,8 @@ "providerNames": { "builtin": "内置", "mem0": "Mem0", - "openviking": "OpenViking" + "openviking": "OpenViking", + "nowledgemem": "Nowledge Mem" } }, "speech": { diff --git a/apps/web/src/pages/memory/components/add-memory-provider.vue b/apps/web/src/pages/memory/components/add-memory-provider.vue index 1b4102b4..b1e5c2b8 100644 --- a/apps/web/src/pages/memory/components/add-memory-provider.vue +++ b/apps/web/src/pages/memory/components/add-memory-provider.vue @@ -40,6 +40,9 @@ {{ $t('memory.providerNames.openviking') }} + + {{ $t('memory.providerNames.nowledgemem') }} + diff --git a/cmd/memoh/serve.go b/cmd/memoh/serve.go index ba8097f6..e091847a 100644 --- a/cmd/memoh/serve.go +++ b/cmd/memoh/serve.go @@ -68,6 +68,7 @@ import ( membuiltin "github.com/memohai/memoh/internal/memory/adapters/builtin" memmem0 "github.com/memohai/memoh/internal/memory/adapters/mem0" memopenviking "github.com/memohai/memoh/internal/memory/adapters/openviking" + memnowledgemem "github.com/memohai/memoh/internal/memory/adapters/nowledgemem" "github.com/memohai/memoh/internal/memory/memllm" storefs "github.com/memohai/memoh/internal/memory/storefs" "github.com/memohai/memoh/internal/message" @@ -279,6 +280,9 @@ func provideMemoryProviderRegistry(log *slog.Logger, llm memprovider.LLM, chatSe registry.RegisterFactory(string(memprovider.ProviderOpenViking), func(_ string, config map[string]any) (memprovider.Provider, error) { return memopenviking.NewOpenVikingProvider(log, config) }) + registry.RegisterFactory(string(memprovider.ProviderNowledgeMem), func(_ string, config map[string]any) (memprovider.Provider, error) { + return memnowledgemem.NewNowledgeMemProvider(log, config) + }) defaultProvider := membuiltin.NewBuiltinProvider(log, builtinRuntime, chatService, accountService) defaultProvider.SetLLM(llm) registry.Register("__builtin_default__", defaultProvider) diff --git a/docs/nowledge-mem.md b/docs/nowledge-mem.md new file mode 100644 index 00000000..8b8c2f80 --- /dev/null +++ b/docs/nowledge-mem.md @@ -0,0 +1,135 @@ +# Nowledge Mem 集成设计文档 + +## 背景 + +Memoh 内置的 builtin memory provider 效果不佳。Nowledge Mem 是一个本地优先的 AI 记忆管理器,具备知识图谱、6 路混合搜索、后台智能(实体抽取、EVOLVES 关系检测、Crystal 合成、衰减评分)等能力,作为替代方案集成到 Memoh 的 memory provider 体系中。 + +## 调研:Nowledge Mem 能力概览 + +### 产品定位 + +- 本地优先的个人知识库,数据默认存储在用户设备 +- 免费,支持 macOS / Windows / Linux +- 为 AI 工具(Claude Code、Cursor 等)提供统一的持久化记忆层 + +### 核心能力 + +| 能力 | 说明 | +|---|---| +| 记忆管理 | CRUD + 标签组织 + 重要性评分 | +| 语义搜索 | 6 路混合:semantic + full-text + entity + community + label + graph traversal | +| 知识图谱 | 自动实体抽取(Person/Concept/...)、关系可视化、遍历深度控制 | +| 蒸馏 (Distill) | 从对话线程中提取关键记忆 | +| Crystal 合成 | 三条以上独立记忆汇聚同一主题时自动合成统一摘要 | +| Decay + Confidence | 双分评分系统:新鲜度(指数衰减 + 频率提升)+ 可信度(只增不减) | +| Working Memory | 每日自动生成 daily briefing | +| 后台智能 | 定时任务:crystallization、community detection、KG extraction、decay refresh | + +### 接入方式 + +- **REST API**:`http://127.0.0.1:14242`,无认证 +- **MCP Server**:`http://127.0.0.1:14242/mcp`(streamableHttp) +- **CLI**:`nmem` 命令行工具 + +## 方案选择 + +### 排除的方案 + +- **方案 B(双向同步)**:Memoh 和 Nowledge Mem 各自有完整的记忆 pipeline,双向同步没有意义 +- **方案 C(MCP 接入)**:不够原生,Agent 不会主动调用 MCP 工具来记忆 + +### 最终方案:方案 A — 作为 Memory Provider + +将 Nowledge Mem 作为 Memoh 的 memory provider 实现,通过 `adapters.Provider` 接口接入: + +- `OnBeforeChat`:搜索相关记忆注入上下文 +- `OnAfterChat`:将对话写入 Nowledge Mem,由其 LLM 做实体抽取和知识图谱构建 +- 搜索、存储、CRUD 全部代理到 Nowledge Mem REST API + +### 为什么不用方案 D(线程归档) + +方案 D 只是事后归档,对 agent 在线对话质量无直接帮助。可作为 A 的补充,不做替代。 + +## 设计决策 + +### 多用户/群聊适配 + +Nowledge Mem 是单用户个人知识库,没有 `user_id` 或 `group_id` 概念。但在多用户维度上,现有的 Mem0 和 OpenViking provider 同样没做隔离(都只按 `bot_id` scope)。 + +关键改进:**在存储文本中嵌入结构化的发言人标识**,让 Nowledge Mem 的实体抽取和全文搜索能区分不同人。 + +### 存储文本格式 + +每条记忆对应一轮对话(用户消息 + bot 回复): + +``` +[张三] 我最近在用 Rust 重写后端 +[我] 很好的选择,Rust 的性能和安全性都很出色 +``` + +- **用户消息**:`[{display-name}] {消息内容}` + - display-name 从 YAML front-matter header 中解析(Memoh 的 user_header.go 在每条用户消息中嵌入了 `display-name` 字段) + - 回退链:YAML header → AfterChatRequest.DisplayName → `"用户"` +- **Bot 消息**:`[我] {消息内容}` + - LLM 读到 `[我]` 时自然理解为"这是我之前说过的话" + - `我` 不是命名实体,不会在知识图谱中产生误导节点 + +### 为什么不用 `(@username)` 双标识 + +AfterChatRequest 中只有 `DisplayName`(人类可读名)和内部 UUID(UserID、ChannelIdentityID),没有平台级 username。YAML header 中也只有 `display-name` 和 `channel-identity-id`(内部 ID)。因此只使用 display-name。 + +### 查询侧 + +直接使用用户消息原文查询 `POST /memories/search`。Nowledge Mem 的 6 路搜索会自动处理: +- 语义搜索命中话题相关记忆 +- 全文搜索命中包含人名的记忆 +- 实体搜索命中 Person 实体关联的记忆 + +不需要在查询时拼接发言人信息。 + +### 上下文注入格式 + +与现有 provider 一致的 `` XML 格式: + +```xml + +Relevant memory context (use when helpful): +- [2025-01-15] [张三] 喜欢用 Rust,推荐过《The Rust Programming Language》 +- [2025-01-10] [李四] 对 Rust 感兴趣但还没开始学习 + +``` + +## 实现 + +### 新建文件 + +- `internal/memory/adapters/nowledgemem/client.go` — HTTP 客户端 +- `internal/memory/adapters/nowledgemem/nowledgemem.go` — Provider 实现 + +### 修改文件 + +- `internal/memory/adapters/types.go` — 添加 `ProviderNowledgeMem` 常量 +- `internal/memory/adapters/service.go` — 验证 + 元数据 +- `cmd/memoh/serve.go` — 注册工厂 +- `apps/web/src/pages/memory/components/add-memory-provider.vue` — UI 下拉选项 +- `packages/sdk/src/types.gen.ts` — TypeScript 类型 +- `apps/web/src/i18n/locales/en.json` / `zh.json` — 国际化 + +### 配置 + +创建 provider 时只需 `base_url`(可选,默认 `http://127.0.0.1:14242`): + +```json +{ + "name": "nmem", + "provider": "nowledgemem", + "config": {} +} +``` + +## 局限性 + +1. **本地依赖**:Nowledge Mem 必须与 Memoh 在同一台机器上运行 +2. **无 GetAll**:Nowledge Mem API 不提供列出所有记忆的端点(带分页的 GET /memories 不含语义排序),GetAll 返回 unsupported error +3. **无 Compact**:记忆整理由 Nowledge Mem 后台智能自动处理(decay refresh、crystallization),不暴露给 Memoh +4. **display-name 可变**:用户改名后,旧记忆中的名字不会更新。但 Nowledge Mem 的 Entity aliases 机制可能缓解此问题 diff --git a/internal/memory/adapters/nowledgemem/client.go b/internal/memory/adapters/nowledgemem/client.go new file mode 100644 index 00000000..365e1438 --- /dev/null +++ b/internal/memory/adapters/nowledgemem/client.go @@ -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] + "..." +} diff --git a/internal/memory/adapters/nowledgemem/nowledgemem.go b/internal/memory/adapters/nowledgemem/nowledgemem.go new file mode 100644 index 00000000..5d63c901 --- /dev/null +++ b/internal/memory/adapters/nowledgemem/nowledgemem.go @@ -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("\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("") + 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 +} diff --git a/internal/memory/adapters/service.go b/internal/memory/adapters/service.go index deda64f3..99b607b5 100644 --- a/internal/memory/adapters/service.go +++ b/internal/memory/adapters/service.go @@ -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 diff --git a/internal/memory/adapters/types.go b/internal/memory/adapters/types.go index 46520112..2e003d78 100644 --- a/internal/memory/adapters/types.go +++ b/internal/memory/adapters/types.go @@ -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 { diff --git a/packages/sdk/src/types.gen.ts b/packages/sdk/src/types.gen.ts index 7d1736ae..acf09a44 100644 --- a/packages/sdk/src/types.gen.ts +++ b/packages/sdk/src/types.gen.ts @@ -271,7 +271,7 @@ export type AdaptersProviderStatusResponse = { provider_type?: string; }; -export type AdaptersProviderType = 'builtin' | 'mem0' | 'openviking'; +export type AdaptersProviderType = 'builtin' | 'mem0' | 'openviking' | 'nowledgemem'; export type AdaptersProviderUpdateRequest = { config?: {