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?: {