Improve Mem0-backed memory workflows

This commit is contained in:
charm-ch
2026-06-17 11:11:48 +08:00
committed by zyairehhh
parent 1f26a5c6d4
commit e27b1c6501
18 changed files with 3108 additions and 65 deletions

View File

@@ -63,6 +63,28 @@ OPENTALKING_AGENT_LIGHTRAG_CHUNK_FALLBACK_ENABLED=false
# OPENTALKING_LLM_API_KEY=<atlascloud-api-key>
# OPENTALKING_LLM_MODEL=deepseek-ai/deepseek-v4-pro
# Agent 记忆库配置 (Character Memory / Mem0)
# 默认使用 Mem0 智能记忆引擎和智能编排;是否在对话中生效由会话/角色的 memory_enabled 控制。
# 当前适配使用 mem0.Memory 开源 SDK不需要 MEM0_API_KEY。
# 多轮对话总结。建议保持开启;窗口越大,摘要越少,但每次摘要覆盖的上下文越长。
OPENTALKING_MEMORY_SUMMARY_ENABLED=true
OPENTALKING_MEMORY_SUMMARY_TURN_WINDOW=8
OPENTALKING_MEMORY_SUMMARY_MAX_ITEMS=3
# Mem0 自身使用的 LLM。OpenAI-compatible 服务可填写对应 base_url。
# OPENTALKING_MEMORY_MEM0_LLM_PROVIDER=openai
# OPENTALKING_MEMORY_MEM0_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# OPENTALKING_MEMORY_MEM0_LLM_API_KEY=
# OPENTALKING_MEMORY_MEM0_LLM_MODEL=qwen-flash
# Mem0 自身使用的 embedding 模型。通常与上面的 LLM 使用同一个 OpenAI-compatible base_url。
# OPENTALKING_MEMORY_MEM0_EMBEDDER_PROVIDER=openai
# OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY=
# OPENTALKING_MEMORY_MEM0_EMBEDDER_MODEL=text-embedding-v4
# =============================================================================
# 2. 语音识别配置 (STT Provider Profiles)
# =============================================================================

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
import pytest
import apps.api.main as api_main
@@ -123,3 +125,127 @@ def test_agent_lightrag_chunk_fallback_can_be_enabled(monkeypatch: pytest.Monkey
settings = Settings(_env_file=None)
assert settings.agent_lightrag_chunk_fallback_enabled is True
def _active_env_names(contents: str) -> set[str]:
names: set[str] = set()
for raw_line in contents.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, _value = line.split("=", 1)
names.add(name)
return names
def test_env_examples_expose_only_user_facing_memory_settings() -> None:
repo_root = Path(__file__).resolve().parents[3]
examples = [
repo_root / ".env.example",
repo_root / "scripts/quickstart/env.example",
]
required_names = {
"OPENTALKING_MEMORY_SUMMARY_ENABLED",
"OPENTALKING_MEMORY_SUMMARY_TURN_WINDOW",
"OPENTALKING_MEMORY_SUMMARY_MAX_ITEMS",
"OPENTALKING_MEMORY_MEM0_LLM_PROVIDER",
"OPENTALKING_MEMORY_MEM0_LLM_BASE_URL",
"OPENTALKING_MEMORY_MEM0_LLM_API_KEY",
"OPENTALKING_MEMORY_MEM0_LLM_MODEL",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_PROVIDER",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_MODEL",
}
hidden_names = {
"OPENTALKING_MEMORY_PROVIDER",
"OPENTALKING_MEMORY_ENABLED",
"OPENTALKING_MEMORY_DEFAULT_PROFILE_ID",
"OPENTALKING_MEMORY_DEFAULT_LIBRARY_ID",
"OPENTALKING_MEMORY_SQLITE_PATH",
"OPENTALKING_MEMORY_RECALL_LIMIT",
"OPENTALKING_MEMORY_RECALL_MIN_SCORE",
"OPENTALKING_MEMORY_RECALL_TIMEOUT_MS",
"OPENTALKING_MEMORY_RECALL_BACKEND",
"OPENTALKING_MEMORY_WRITE_MODE",
"OPENTALKING_MEMORY_DECISION_MODE",
"OPENTALKING_MEMORY_DECISION_TIMEOUT_MS",
"OPENTALKING_MEMORY_SMART_WRITE_ENABLED",
"OPENTALKING_MEMORY_MEM0_CONFIG",
}
for example in examples:
contents = example.read_text(encoding="utf-8")
missing = sorted(name for name in required_names if name not in contents)
exposed = sorted(name for name in hidden_names if name in contents)
assert not missing, f"{example} is missing user-facing memory settings: {missing}"
assert not exposed, f"{example} exposes internal memory settings: {exposed}"
def test_root_env_example_keeps_memory_summary_enabled_by_default() -> None:
repo_root = Path(__file__).resolve().parents[3]
contents = (repo_root / ".env.example").read_text(encoding="utf-8")
values = {}
for raw_line in contents.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
values[name] = value
assert values["OPENTALKING_MEMORY_SUMMARY_ENABLED"] == "true"
assert values["OPENTALKING_MEMORY_SUMMARY_TURN_WINDOW"] == "8"
assert values["OPENTALKING_MEMORY_SUMMARY_MAX_ITEMS"] == "3"
def test_memory_engine_defaults_are_smart_but_session_scoped(monkeypatch: pytest.MonkeyPatch) -> None:
for name in [
"OPENTALKING_MEMORY_PROVIDER",
"OPENTALKING_MEMORY_ENABLED",
"OPENTALKING_MEMORY_RECALL_BACKEND",
"OPENTALKING_MEMORY_WRITE_MODE",
"OPENTALKING_MEMORY_DECISION_MODE",
"OPENTALKING_MEMORY_DECISION_TIMEOUT_MS",
"OPENTALKING_MEMORY_RECALL_TIMEOUT_MS",
"OPENTALKING_MEMORY_SMART_WRITE_ENABLED",
"OPENTALKING_MEMORY_SUMMARY_ENABLED",
"OPENTALKING_MEMORY_SUMMARY_TURN_WINDOW",
"OPENTALKING_MEMORY_SUMMARY_MAX_ITEMS",
]:
monkeypatch.delenv(name, raising=False)
settings = Settings(_env_file=None)
assert settings.memory_provider == "mem0"
assert settings.memory_enabled is False
assert settings.memory_recall_backend == "hybrid"
assert settings.memory_write_mode == "hybrid"
assert settings.memory_decision_mode == "hybrid"
assert settings.memory_decision_timeout_ms == 2000
assert settings.memory_recall_timeout_ms == 2000
assert settings.memory_smart_write_enabled is True
assert settings.memory_summary_enabled is True
assert settings.memory_summary_turn_window == 8
assert settings.memory_summary_max_items == 3
def test_memory_engine_settings_read_prefixed_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENTALKING_MEMORY_RECALL_BACKEND", "mem0")
monkeypatch.setenv("OPENTALKING_MEMORY_WRITE_MODE", "mem0")
monkeypatch.setenv("OPENTALKING_MEMORY_DECISION_MODE", "hybrid")
monkeypatch.setenv("OPENTALKING_MEMORY_DECISION_TIMEOUT_MS", "1200")
monkeypatch.setenv("OPENTALKING_MEMORY_SMART_WRITE_ENABLED", "false")
monkeypatch.setenv("OPENTALKING_MEMORY_SUMMARY_ENABLED", "true")
monkeypatch.setenv("OPENTALKING_MEMORY_SUMMARY_TURN_WINDOW", "4")
monkeypatch.setenv("OPENTALKING_MEMORY_SUMMARY_MAX_ITEMS", "2")
settings = Settings(_env_file=None)
assert settings.memory_recall_backend == "mem0"
assert settings.memory_write_mode == "mem0"
assert settings.memory_decision_mode == "hybrid"
assert settings.memory_decision_timeout_ms == 1200
assert settings.memory_smart_write_enabled is False
assert settings.memory_summary_enabled is True
assert settings.memory_summary_turn_window == 4
assert settings.memory_summary_max_items == 2

View File

@@ -80,6 +80,8 @@ memory:
encoding="utf-8",
)
monkeypatch.setenv("OPENTALKING_CONFIG_FILE", str(config_file))
monkeypatch.setenv("OPENTALKING_MEMORY_PROVIDER", "sqlite")
monkeypatch.setenv("OPENTALKING_MEMORY_SQLITE_PATH", str(tmp_path / "memory.sqlite3"))
get_settings.cache_clear()
app = FastAPI()
@@ -129,6 +131,8 @@ models:
encoding="utf-8",
)
monkeypatch.setenv("OPENTALKING_CONFIG_FILE", str(config_file))
monkeypatch.setenv("OPENTALKING_MEMORY_PROVIDER", "sqlite")
monkeypatch.setenv("OPENTALKING_MEMORY_SQLITE_PATH", str(tmp_path / "memory.sqlite3"))
get_settings.cache_clear()
settings = get_settings()
redis = InMemoryRedis()

View File

@@ -45,13 +45,21 @@ tts:
sample_rate: 16000
streaming_decode: true
memory:
provider: sqlite
provider: mem0
enabled: false
default_profile_id: default
default_library_id: default
recall_limit: 5
recall_min_score: 0.0
recall_timeout_ms: 80
recall_timeout_ms: 2000
recall_backend: hybrid
write_mode: hybrid
decision_mode: hybrid
decision_timeout_ms: 2000
smart_write_enabled: true
summary_enabled: true
summary_turn_window: 8
summary_max_items: 3
sqlite_path: ./data/opentalking_memory.sqlite3
model:
torch_device: cpu

View File

@@ -0,0 +1,158 @@
# Mem0 Memory Engine
The Mem0 memory engine powers long-term character memory in OpenTalking. It stores durable user preferences, stable facts, and multi-turn summaries, then recalls relevant memories before the next LLM call. This is separate from the knowledge-base/RAG flow: knowledge bases are for uploaded documents, while memory libraries are for long-lived user-character interaction state.
!!! note "Current implementation"
OpenTalking currently integrates Mem0 through the open-source `mem0.Memory` SDK and keeps its own `/memory/*` APIs, memory libraries, items, and scope model. Switching the provider to Mem0 does not remove the existing list/import/delete memory API surface.
## Where Mem0 Fits
```mermaid
flowchart TD
User["User input"] --> Runtime["MemoryRuntime"]
Runtime --> Decision["MemoryDecisionAgent<br/>recall/write decision"]
Decision --> Recall{"Recall?"}
Recall -->|yes| Search["Mem0 search<br/>or hybrid fallback"]
Search --> Prompt["Memory snippets injected into LLM prompt"]
Decision --> Write{"Write?"}
Write -->|smart write| AddInfer["Mem0 add(messages, infer=true)"]
Write -->|raw write| AddRaw["Mem0 add(text, infer=false)"]
Runtime --> Summary{"Summary enabled?"}
Summary -->|window reached| Summarize["OpenTalking LLM creates summary"]
Summarize --> AddSummary["Mem0 add(summary, infer=false)"]
```
OpenTalking still owns:
- Scope: `profile_id + character_id + library_id`.
- Memory APIs: create libraries, list items, import turns, delete items.
- Recall decisions: low-value inputs are skipped.
- Multi-turn summary generation: OpenTalking uses the configured main LLM, then writes the summary through the provider.
Mem0 provides:
- Memory storage and semantic search.
- Smart extraction from conversation messages when `smart_write_enabled=true` and the installed SDK supports `infer`.
- Mem0-side semantic recall, deduplication, and disambiguation behavior. OpenTalking does not expose a separate manual disambiguation endpoint.
## Dependencies
The current 146 environment uses the compatible set below:
```bash title="Terminal"
python -m pip install "mem0ai==0.1.60" "qdrant-client==1.12.0" "protobuf==4.25.9"
```
`protobuf` is pinned to 4.x to avoid conflicts between newer `mem0ai` releases and the existing `mediapipe` dependency. Re-run `python -m pip check` and the memory tests before upgrading the Mem0 SDK.
## .env Configuration
OpenTalking defaults to the Mem0 smart memory engine and smart orchestration, but conversation memory still runs only when the session/persona enables `memory_enabled`: if memory is not enabled for that conversation, OpenTalking does not recall or write memories. When memory is enabled and no explicit library is provided, OpenTalking uses its internal default memory library.
Most deployments only need the summary settings and the models Mem0 uses internally:
```env title=".env"
OPENTALKING_MEMORY_SUMMARY_ENABLED=true
OPENTALKING_MEMORY_SUMMARY_TURN_WINDOW=8
OPENTALKING_MEMORY_SUMMARY_MAX_ITEMS=3
OPENTALKING_MEMORY_MEM0_LLM_PROVIDER=openai
OPENTALKING_MEMORY_MEM0_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
OPENTALKING_MEMORY_MEM0_LLM_API_KEY=<llm-api-key>
OPENTALKING_MEMORY_MEM0_LLM_MODEL=qwen-flash
OPENTALKING_MEMORY_MEM0_EMBEDDER_PROVIDER=openai
OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY=<embedding-api-key>
OPENTALKING_MEMORY_MEM0_EMBEDDER_MODEL=text-embedding-v4
```
Internal defaults already select the Mem0 provider, hybrid recall/write, rules-first plus Mem0/LLM second-stage judgement, and smart writes. `OPENTALKING_MEMORY_MEM0_CONFIG` is still supported as an advanced compatibility override: when it is set, OpenTalking passes that JSON to Mem0 instead of building config from the split variables above.
## LLM, Embedding, And Vector Store
| Config | Purpose |
|--------|---------|
| `llm` | Model Mem0 uses to understand conversations and extract or rewrite durable memories. |
| `embedder` | Model that converts memory text into vectors for semantic retrieval. |
| `vector_store` | Storage backend for vectors, metadata, and indexes, such as Qdrant, Chroma, or pgvector. It is not the OpenTalking memory-library UI; it is Mem0's retrieval database. |
OpenTalking's main LLM is still configured with `OPENTALKING_LLM_*`. The `llm/embedder/vector_store` block only affects Mem0's own memory extraction and retrieval.
## Recall Decision Mode
`OPENTALKING_MEMORY_DECISION_MODE` controls the pre-recall decision:
| Value | Behavior |
|-------|----------|
| `rule` | Default. Uses local rules only, with the lowest latency. |
| `hybrid` | Rules run first. Empty inputs, low-value inputs, and high-risk operations are hard rejects; clear user-memory, fact-entity, and explicit-recall matches recall directly; only ambiguous inputs call the LLM judge. |
| `llm` | Experimental. Hard rejects remain rule-protected; other inputs are judged by the LLM. |
`OPENTALKING_MEMORY_DECISION_TIMEOUT_MS` is the second-stage judge timeout. If the LLM judge times out or fails, OpenTalking falls back to the rule result and continues the conversation.
## Is MEM0_API_KEY Required?
For the current OpenTalking adapter, no. It uses the open-source `mem0.Memory` class, not `MemoryClient`, so `MEM0_API_KEY` is not required. You only need the LLM, embedding, and vector-store configuration used by the open-source Mem0 runtime; those services may have their own API keys or local endpoints.
`MEM0_API_KEY` is required only if the project later switches to Mem0 Platform / `MemoryClient`. Platform pricing depends on Mem0's current plans and is not required for this adapter.
## Impact On Existing Memory APIs
With `OPENTALKING_MEMORY_PROVIDER=mem0`, the OpenTalking memory API remains the same:
```bash title="Terminal"
curl "http://127.0.0.1:8000/memory/libraries?profile_id=default&character_id=<avatar-id>"
curl "http://127.0.0.1:8000/memory/libraries/default/items?profile_id=default&character_id=<avatar-id>"
```
Notes:
- Item listing uses Mem0 `get_all(...)`, then OpenTalking filters by `profile_id`, `character_id`, and `library_id`.
- OpenTalking stores the raw Mem0 id as `_mem0_id` and keeps its own `opentalking_memory_id` for stable API-facing ids.
- Item deletion uses `_mem0_id` when calling Mem0 delete.
- `memory_recall_backend=hybrid` falls back to local BM25 item ranking when Mem0 returns no result; `memory_recall_backend=mem0` uses Mem0 only.
## Verification
Check provider initialization:
```bash title="Terminal"
python - <<'PY'
from opentalking.core.config import get_settings
from opentalking.providers.memory.factory import build_memory_provider
get_settings.cache_clear()
provider = build_memory_provider()
print(type(provider).__name__)
PY
```
Import and list one memory:
```bash title="Terminal"
curl -s -X POST http://127.0.0.1:8000/memory/libraries \
-H 'content-type: application/json' \
-d '{"id":"default","name":"Default","character_id":"demo-avatar"}'
curl -s -X POST http://127.0.0.1:8000/memory/libraries/default/import \
-H 'content-type: application/json' \
-d '{"profile_id":"default","character_id":"demo-avatar","turns":[{"role":"user","content":"Remember that I prefer concise answers."}]}'
curl -s "http://127.0.0.1:8000/memory/libraries/default/items?profile_id=default&character_id=demo-avatar"
```
After changing SDK versions or dependencies, run:
```bash title="Terminal"
python -m pip check
python -m pytest tests/unit/test_memory_provider.py apps/api/tests/test_memory_api.py apps/api/tests/test_config.py
```
## References
- [Mem0 Open Source Overview](https://docs.mem0.ai/open-source/overview)
- [Mem0 Open Source Configuration](https://docs.mem0.ai/open-source/configuration)
- [Mem0 Platform Quickstart](https://docs.mem0.ai/platform/quickstart)
- [Mem0 Pricing](https://mem0.ai/pricing)

View File

@@ -0,0 +1,156 @@
# Mem0 记忆引擎
Mem0 记忆引擎用于 OpenTalking 的长期角色记忆:记录用户偏好、稳定事实、多轮对话摘要,并在后续对话前召回相关记忆注入 LLM 上下文。它不同于“知识库/RAG”知识库面向上传文档和业务资料记忆库面向用户和角色之间的长期互动状态。
!!! note "当前实现"
OpenTalking 当前通过 `mem0.Memory` 开源 SDK 接入 Mem0并继续使用自己的 `/memory/*` API、记忆库、条目和作用域模型。也就是说切换到 Mem0 后,前端和 API 的“查看记忆库条目、导入、删除”等能力仍走 OpenTalking 的统一 `MemoryProvider` 接口。
## Mem0 在流程中的位置
```mermaid
flowchart TD
User["用户输入"] --> Runtime["MemoryRuntime"]
Runtime --> Decision["MemoryDecisionAgent<br/>判断是否召回/写入"]
Decision --> Recall{"需要召回?"}
Recall -->|是| Search["Mem0 search<br/>或 hybrid fallback"]
Search --> Prompt["记忆片段注入 LLM prompt"]
Decision --> Write{"需要写入?"}
Write -->|smart write| AddInfer["Mem0 add(messages, infer=true)"]
Write -->|raw write| AddRaw["Mem0 add(text, infer=false)"]
Runtime --> Summary{"开启多轮总结?"}
Summary -->|达到窗口| Summarize["OpenTalking LLM 生成摘要"]
Summarize --> AddSummary["Mem0 add(summary, infer=false)"]
```
OpenTalking 仍负责:
- 记忆作用域:`profile_id + character_id + library_id`
- 记忆 API创建记忆库、列出条目、导入对话、删除条目。
- 召回决策:低价值输入不会触发记忆召回。
- 多轮摘要:由 OpenTalking 使用已配置的主 LLM 生成摘要,再写入 provider。
Mem0 负责:
- 存储和检索记忆。
-`smart_write_enabled=true` 且 SDK 支持 `infer` 时,从对话消息里抽取更适合长期保存的记忆。
- 对记忆执行语义召回、去重和歧义处理能力OpenTalking 侧不会额外暴露一个独立的“手动消歧”接口。
## 安装依赖
```bash title="终端"
python -m pip install "mem0ai==0.1.60" "qdrant-client==1.12.0" "protobuf==4.25.9"
```
这里固定 `protobuf` 4.x 是为了避免新版 `mem0ai` 依赖链和项目中的 `mediapipe` 冲突。后续如果升级 Mem0 SDK需要先重新跑 `python -m pip check` 和记忆相关测试。
## .env 配置
OpenTalking 默认使用 Mem0 智能记忆引擎和智能编排,但是否在对话中生效仍由会话/角色侧的 `memory_enabled` 控制:未开启记忆能力时,不召回、不写入;开启后如果没有传入具体记忆库,系统使用内部默认记忆库。
普通部署只需要配置多轮总结和 Mem0 自身使用的模型:
```env title=".env"
OPENTALKING_MEMORY_SUMMARY_ENABLED=true
OPENTALKING_MEMORY_SUMMARY_TURN_WINDOW=8
OPENTALKING_MEMORY_SUMMARY_MAX_ITEMS=3
OPENTALKING_MEMORY_MEM0_LLM_PROVIDER=openai
OPENTALKING_MEMORY_MEM0_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
OPENTALKING_MEMORY_MEM0_LLM_API_KEY=<llm-api-key>
OPENTALKING_MEMORY_MEM0_LLM_MODEL=qwen-flash
OPENTALKING_MEMORY_MEM0_EMBEDDER_PROVIDER=openai
OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY=<embedding-api-key>
OPENTALKING_MEMORY_MEM0_EMBEDDER_MODEL=text-embedding-v4
```
内部默认值已经设置为 Mem0 provider、hybrid 召回/写入、规则闸门 + Mem0/LLM 二级判断、智能写入开启。`OPENTALKING_MEMORY_MEM0_CONFIG` 仍作为高级兼容配置保留:如果设置了它,会优先使用这段 JSON 并覆盖上面的拆分配置。
## LLM、embedding 和 vector store 是什么
| 配置 | 作用 |
|------|------|
| `llm` | Mem0 用来理解对话、抽取或改写长期记忆的模型配置。 |
| `embedder` | 把记忆文本转成向量,用于语义相似度检索。 |
| `vector_store` | 保存向量、元数据和索引的存储后端,例如 Qdrant、Chroma、pgvector 等。它不是 OpenTalking 的“记忆库 UI”而是 Mem0 底层检索所需的向量数据库。 |
OpenTalking 的主 LLM 配置仍由 `OPENTALKING_LLM_*` 控制Mem0 的 `llm/embedder/vector_store` 配置只影响 Mem0 自己的记忆抽取和检索。
## 召回决策模式
`OPENTALKING_MEMORY_DECISION_MODE` 控制召回前置判断:
| 值 | 行为 |
|----|------|
| `rule` | 默认值。只使用本地规则判断是否召回,延迟最低。 |
| `hybrid` | 规则先判;空输入、低价值输入、高风险操作等硬拒绝不会调用 LLM明确命中用户信息、事实实体、显式回忆标记时直接召回只有模糊输入再调用 LLM 二级判断。 |
| `llm` | 实验模式。硬拒绝仍保留,其余输入交给 LLM judge 判断。 |
`OPENTALKING_MEMORY_DECISION_TIMEOUT_MS` 是二级判断超时时间。LLM judge 超时或失败时,会回退到规则结果,不中断主对话。
## 是否需要 MEM0_API_KEY
当前 OpenTalking 适配层使用 Mem0 开源 SDK 的 `mem0.Memory`,不使用 `MemoryClient`,所以不需要 `MEM0_API_KEY`。你需要配置的是 Mem0 开源版所依赖的 LLM、embedding 和 vector store这些服务本身可能需要各自的 API key 或本地服务地址。
只有切换到 Mem0 Platform / `MemoryClient` 时,才需要 `MEM0_API_KEY`。平台版是否收费取决于 Mem0 官方当前套餐;本项目不把平台版 API key 作为当前必需项。
## 对现有记忆库功能的影响
切换 `OPENTALKING_MEMORY_PROVIDER=mem0` 后OpenTalking 的记忆库接口仍保持不变:
```bash title="终端"
curl "http://127.0.0.1:8000/memory/libraries?profile_id=default&character_id=<avatar-id>"
curl "http://127.0.0.1:8000/memory/libraries/default/items?profile_id=default&character_id=<avatar-id>"
```
需要注意:
- 条目列表来自 Mem0 的 `get_all(...)`OpenTalking 会按 `profile_id`、`character_id`、`library_id` 再过滤。
- OpenTalking 会把 Mem0 原始 id 保存到 `_mem0_id`,并保留自己的 `opentalking_memory_id`,避免前端条目 id 因底层 provider 切换而失效。
- 删除条目时会用 `_mem0_id` 调 Mem0 删除;如果 Mem0 返回格式变化,需要优先检查 id 映射。
- 如果 `memory_recall_backend=hybrid`Mem0 搜不到结果时会回退到本地 BM25 条目排序;如果设为 `mem0`,则只使用 Mem0 召回。
## 验证
检查配置是否能初始化:
```bash title="终端"
python - <<'PY'
from opentalking.core.config import get_settings
from opentalking.providers.memory.factory import build_memory_provider
get_settings.cache_clear()
provider = build_memory_provider()
print(type(provider).__name__)
PY
```
导入一条记忆并列出:
```bash title="终端"
curl -s -X POST http://127.0.0.1:8000/memory/libraries \
-H 'content-type: application/json' \
-d '{"id":"default","name":"Default","character_id":"demo-avatar"}'
curl -s -X POST http://127.0.0.1:8000/memory/libraries/default/import \
-H 'content-type: application/json' \
-d '{"profile_id":"default","character_id":"demo-avatar","turns":[{"role":"user","content":"记住,我喜欢简洁回答。"}]}'
curl -s "http://127.0.0.1:8000/memory/libraries/default/items?profile_id=default&character_id=demo-avatar"
```
建议每次调整 SDK 或依赖后运行:
```bash title="终端"
python -m pip check
python -m pytest tests/unit/test_memory_provider.py apps/api/tests/test_memory_api.py apps/api/tests/test_config.py
```
## 参考资料
- [Mem0 Open Source Overview](https://docs.mem0.ai/open-source/overview)
- [Mem0 Open Source Configuration](https://docs.mem0.ai/open-source/configuration)
- [Mem0 Platform Quickstart](https://docs.mem0.ai/platform/quickstart)
- [Mem0 Pricing](https://mem0.ai/pricing)

View File

@@ -126,6 +126,7 @@ plugins:
Models: 模型
Support Matrix: 支持矩阵
LLM and STT: LLM 与 STT
Mem0 Memory Engine: Mem0 记忆引擎
TTS: TTS
Text-to-Speech: 语音合成
Backend Modes: 后端模式
@@ -272,6 +273,7 @@ nav:
- Local Audio + QuickTalk: model-deployment/recipes/local-quicktalk-audio.md
- V100 + FasterLivePortrait + FlashHead: model-deployment/recipes/v100-fasterliveportrait-flashhead.md
- LLM and STT: model-deployment/llm-stt.md
- Mem0 Memory Engine: model-deployment/mem0-memory.md
- TTS: model-deployment/tts.md
- Support Matrix: model-deployment/support-matrix.md
- Reference Materials:
@@ -351,6 +353,7 @@ nav:
- Local Audio + QuickTalk: model-deployment/recipes/local-quicktalk-audio.md
- V100 + FasterLivePortrait + FlashHead: model-deployment/recipes/v100-fasterliveportrait-flashhead.md
- LLM and STT: model-deployment/llm-stt.md
- Mem0 Memory Engine: model-deployment/mem0-memory.md
- TTS: model-deployment/tts.md
- Support Matrix: model-deployment/support-matrix.md
- Reference Materials:

View File

@@ -82,7 +82,30 @@ def _flatten_config(raw: dict[str, Any] | None) -> dict[str, Any]:
"recall_limit": "memory_recall_limit",
"recall_min_score": "memory_recall_min_score",
"recall_timeout_ms": "memory_recall_timeout_ms",
"recall_backend": "memory_recall_backend",
"write_mode": "memory_write_mode",
"decision_mode": "memory_decision_mode",
"decision_timeout_ms": "memory_decision_timeout_ms",
"smart_write_enabled": "memory_smart_write_enabled",
"summary_enabled": "memory_summary_enabled",
"summary_turn_window": "memory_summary_turn_window",
"summary_max_items": "memory_summary_max_items",
"mem0_config": "memory_mem0_config",
"mem0_llm_provider": "memory_mem0_llm_provider",
"mem0_llm_base_url": "memory_mem0_llm_base_url",
"mem0_llm_api_key": "memory_mem0_llm_api_key",
"mem0_llm_model": "memory_mem0_llm_model",
"mem0_embedder_provider": "memory_mem0_embedder_provider",
"mem0_embedder_base_url": "memory_mem0_embedder_base_url",
"mem0_embedder_api_key": "memory_mem0_embedder_api_key",
"mem0_embedder_model": "memory_mem0_embedder_model",
"mem0_embedder_embedding_dims": "memory_mem0_embedder_embedding_dims",
"mem0_vector_store_provider": "memory_mem0_vector_store_provider",
"mem0_vector_store_collection_name": "memory_mem0_vector_store_collection_name",
"mem0_vector_store_path": "memory_mem0_vector_store_path",
"mem0_vector_store_host": "memory_mem0_vector_store_host",
"mem0_vector_store_port": "memory_mem0_vector_store_port",
"mem0_vector_store_embedding_model_dims": "memory_mem0_vector_store_embedding_model_dims",
"sqlite_path": "memory_sqlite_path",
},
"tts": {
@@ -412,14 +435,37 @@ class Settings(BaseSettings):
agent_lightrag_chunk_fallback_enabled: bool = Field(default=False)
# ---- Character memory provider ----
memory_provider: str = "none"
memory_provider: str = "mem0"
memory_enabled: bool = False
memory_default_profile_id: str = "default"
memory_default_library_id: str = "default"
memory_recall_limit: int = 5
memory_recall_min_score: float = 0.0
memory_recall_timeout_ms: int = 80
memory_recall_timeout_ms: int = 2000
memory_recall_backend: str = "hybrid"
memory_write_mode: str = "hybrid"
memory_decision_mode: str = "hybrid"
memory_decision_timeout_ms: int = 2000
memory_smart_write_enabled: bool = True
memory_summary_enabled: bool = True
memory_summary_turn_window: int = 8
memory_summary_max_items: int = 3
memory_mem0_config: str = ""
memory_mem0_llm_provider: str = "openai"
memory_mem0_llm_base_url: str = ""
memory_mem0_llm_api_key: str = ""
memory_mem0_llm_model: str = "qwen-flash"
memory_mem0_embedder_provider: str = "openai"
memory_mem0_embedder_base_url: str = ""
memory_mem0_embedder_api_key: str = ""
memory_mem0_embedder_model: str = "text-embedding-v4"
memory_mem0_embedder_embedding_dims: int = 1024
memory_mem0_vector_store_provider: str = "qdrant"
memory_mem0_vector_store_collection_name: str = "opentalking_memories"
memory_mem0_vector_store_path: str = "./data/mem0_qdrant"
memory_mem0_vector_store_host: str = ""
memory_mem0_vector_store_port: int = 0
memory_mem0_vector_store_embedding_model_dims: int = 1024
memory_sqlite_path: str = "./data/opentalking_memory.sqlite3"
#: CosyVoice 复刻时,百炼需拉取公网 URL若留空则用请求的 Host 拼 URL内网部署请填公网可达地址

View File

@@ -7,6 +7,14 @@ from collections.abc import Iterable, Sequence
from opentalking.providers.memory.schemas import MemoryItem
_CATEGORY_PROMPT_GROUPS = (
("user_preference", "Preferences"),
("entity_relation", "Important people/entities"),
("goal_progress", "Goals and progress"),
("decision_plan", "Decisions and plans"),
("feedback_correction", "Interaction feedback"),
("episode_summary", "Conversation summaries"),
)
_TOKEN_RE = re.compile(r"[\w\u4e00-\u9fff]+", re.UNICODE)
_IP_RE = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b")
_SERVER_ALIAS_RE = re.compile(r"(?<![A-Za-z0-9_])(\d{2,4})\s*(服务器)")
@@ -106,7 +114,31 @@ def rank_items_bm25(
def memories_to_prompt(items: Iterable[MemoryItem]) -> str:
lines = [item.text.strip() for item in items if item.text.strip()]
materialized = [item for item in items if item.text.strip()]
if any((item.metadata or {}).get("category") for item in materialized):
grouped: dict[str, list[str]] = {category: [] for category, _label in _CATEGORY_PROMPT_GROUPS}
other: list[str] = []
for item in materialized:
line = item.text.strip()
category = str((item.metadata or {}).get("category") or "").strip()
if not category and item.type == "preference":
category = "user_preference"
if category in grouped:
grouped[category].append(line)
else:
other.append(line)
sections: list[str] = []
for category, label in _CATEGORY_PROMPT_GROUPS:
lines = grouped[category]
if lines:
sections.append(f"{label}:\n" + "\n".join(f"- {line}" for line in lines))
if other:
sections.append("Other memories:\n" + "\n".join(f"- {line}" for line in other))
if not sections:
return ""
return "Relevant user memories:\n\n" + "\n\n".join(sections)
lines = [item.text.strip() for item in materialized]
if not lines:
return ""
body = "\n".join(f"- {line}" for line in lines)

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
import json
import re
from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
from opentalking.providers.memory.schemas import MemoryItem, utc_now_iso
@@ -23,6 +25,81 @@ _HIGH_RISK_RE = re.compile(
r"\bpush\b|\bmerge\b|\brm\s+-rf\b|\bdrop\s+table\b)",
re.IGNORECASE,
)
_SENSITIVE_RE = re.compile(
r"(\bapi[_ -]?key\b|\btoken\b|\bsecret\b|\bpassword\b|密码|口令|私钥|密钥|sk-[A-Za-z0-9_-]{6,})",
re.IGNORECASE,
)
_FEEDBACK_CORRECTION_RE = re.compile(
r"(不是.+是|不对|纠正|更正|你刚才太|你刚刚太|别总|不要总|这个称呼我不喜欢|我不是.+)"
)
_MEMORY_CHECK_QUESTION_RE = re.compile(
r"((你|还|你还).{0,8}(记得|记住).{0,40}(吗|么|\?|)|"
r"(记住|记得).{0,20}(了吗|了么|吗|么|\?|))"
)
_RECALL_WRITE_QUESTION_RE = re.compile(
r"((按|照|根据).{0,12}(我|我的|之前|以前|上次|刚才).{0,24}"
r"(方式|风格|偏好|习惯|回答|说法|计划|目标).{0,40}"
r"(解释|回答|说|讲|怎么|如何|吗|么|\?|)|"
r"(我的|我现在|我之前|我上次|我刚才).{0,30}"
r"(是什么|是啥|叫啥|哪个|哪些|什么|多少|目标|项目|偏好|习惯|名字))"
)
_ASSISTANT_CONTEXT_CONFIRMATION_RE = re.compile(
r"((好|可以|行|嗯|没问题|同意|接受|就这样).{0,20}"
r"(以后|之后|下次|就按|按|这样|这么|方式|方案|计划|练|记住)|"
r"(以后|之后|下次).{0,12}(就按|按).{0,20}"
r"(你刚才|刚才|这个|这个方式|这个方案|这个计划))"
)
_ENTITY_RELATION_RE = re.compile(
r"(我(女朋友|男朋友|老婆|老公|前女友|前男友|朋友|同事|妈妈|爸爸|母亲|父亲|老师|孩子|儿子|女儿).{0,12}叫|"
r"我家(猫|狗|宠物).{0,12}叫|我在.{1,12}(工作|上学|生活)|我最近在(学|准备|练).{1,20})"
)
_RELATION_WORDS = (
"女朋友",
"男朋友",
"老婆",
"老公",
"前女友",
"前男友",
"朋友",
"同事",
"妈妈",
"爸爸",
"母亲",
"父亲",
"老师",
"孩子",
"儿子",
"女儿",
)
_RELATION_DECLARATION_RE = re.compile(
rf"我(?P<relation>{'|'.join(_RELATION_WORDS)}).{{0,12}}叫(?P<name>[\u4e00-\u9fffA-Za-z0-9_-]{{1,20}})"
)
_RELATION_CORRECTION_RE = re.compile(
rf"不是(?P<old>{'|'.join(_RELATION_WORDS)}).{{0,8}}是(?P<new>{'|'.join(_RELATION_WORDS)})"
)
_GOAL_PROGRESS_RE = re.compile(
r"(完成|背完|跑完|做完|练完|学完|达成|结束了|面试结束|今天.*(背|跑|练|学|复习).*(了|完))"
)
_GOAL_INTENT_RE = re.compile(
r"(我(在|正在|最近在|这段时间在)?(.{0,10})(准备|备考|学习|复习|练习|练).{0,30}"
r"(雅思|托福|考试|英语|日语|口语|面试|健身|跑步|背单词)|"
r"(准备|备考).{0,20}(雅思|托福|考试|英语|日语|口语|面试))"
)
_DECISION_PLAN_RE = re.compile(
r"((那就|就按|决定|计划|以后|之后|下次|每天|每晚|提醒我|开始).{0,40}"
r"(提醒|复盘|背|学|练|计划|方式|这样|这么|做|开始)|以后.*(提醒|叫|按|这样|这种))"
)
_MEDIUM_MEMORY_RE = re.compile(
r"(最近|这段时间|可能|想|打算|准备|希望).{0,40}(学习|英语|日语|健身|运动|睡|焦虑|压力|状态|聊天|陪练)"
)
_SUMMARY_VALUE_RE = re.compile(r"(累|难过|压力|焦虑|随便聊|陪我|今天|最近|感觉|心情|状态|想聊|聊天)")
_COMFORT_CONTEXT_RE = re.compile(r"(压力|难过|焦虑|失眠|累|崩溃|安慰|陪我|陪.*聊|心情)")
_GOAL_CONTEXT_RE = re.compile(r"(背单词|学习|复习|考试|雅思|英语|日语|健身|跑步|提醒我|目标|计划|进度)")
_PREFERENCE_CONTEXT_RE = re.compile(r"(适合我的|我适合|推荐.*(衣服|穿搭|商品|礼物)|预算|尺码|风格|喜欢|不喜欢)")
_NAMED_ENTITY_QUESTION_RE = re.compile(
r"^[\s\"'“”‘’]*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9_-]{1,20})"
r"(是谁|是谁呀|是谁啊|是什么人|跟我什么关系|和我什么关系)[?]?$"
)
_PREFERENCE_MARKERS = (
"i like",
"i love",
@@ -47,6 +124,15 @@ _PREFERENCE_MARKERS = (
"下次",
"之后",
"以后",
"更喜欢",
"希望你",
"别叫我",
"别总",
"不要总",
"温柔",
"简洁",
"说教",
"官方",
)
_EXPLICIT_RECALL_MARKERS = (
"remember",
@@ -89,6 +175,16 @@ class RecallDecision:
should_recall: bool
query: str = ""
reason: str = ""
categories: tuple[str, ...] = ()
@dataclass(frozen=True)
class MemoryWriteDecision:
action: str
category: str = ""
confidence: str = ""
reason: str = ""
items: list[MemoryItem] = field(default_factory=list)
class MemoryDecisionAgent:
@@ -107,13 +203,58 @@ class MemoryDecisionAgent:
lower = text.lower()
if _USER_OWNED_RECALL_RE.search(text) or _USER_NAME_RECALL_RE.search(text):
return RecallDecision(True, query=text, reason="user_owned")
return RecallDecision(
True,
query=text,
reason="user_owned",
categories=("user_preference", "entity_relation", "goal_progress", "decision_plan"),
)
if _HIGH_RISK_RE.search(text) and _FACT_ENTITY_RE.search(text):
return RecallDecision(False, reason="high_risk_ignored")
if _COMFORT_CONTEXT_RE.search(text):
return RecallDecision(
True,
query=text,
reason="comfort_context",
categories=("user_preference", "feedback_correction", "entity_relation"),
)
if _GOAL_CONTEXT_RE.search(text):
return RecallDecision(
True,
query=text,
reason="goal_context",
categories=("goal_progress", "decision_plan", "user_preference"),
)
if _PREFERENCE_CONTEXT_RE.search(text):
return RecallDecision(
True,
query=text,
reason="preference_context",
categories=("user_preference", "feedback_correction", "decision_plan"),
)
if _FACT_ENTITY_RE.search(text):
return RecallDecision(True, query=text, reason="fact_entity")
return RecallDecision(True, query=text, reason="fact_entity", categories=("entity_relation", "note"))
if any(marker in lower for marker in _EXPLICIT_RECALL_MARKERS):
return RecallDecision(True, query=text, reason="explicit_recall")
return RecallDecision(
True,
query=text,
reason="explicit_recall",
categories=(
"user_preference",
"decision_plan",
"goal_progress",
"entity_relation",
"feedback_correction",
"episode_summary",
),
)
if _NAMED_ENTITY_QUESTION_RE.search(text):
return RecallDecision(
True,
query=text,
reason="named_entity_question",
categories=("entity_relation", "feedback_correction", "episode_summary"),
)
return RecallDecision(False, reason="no_marker")
def decide_import(
@@ -128,10 +269,19 @@ class MemoryDecisionAgent:
content = (turn.get("content") or "").strip()
if role != "user" or not self._should_store_import(content):
continue
kind = self._classify(content)
category, confidence, reason = self._classify_write_candidate(content, import_mode=True)
kind = self._classify(content, category=category)
metadata = {"role": role}
if source:
metadata["source"] = source
metadata.update(
{
"category": category,
"confidence": confidence,
"decision_reason": reason,
"source_type": "import",
}
)
items.append(
MemoryItem(
id="",
@@ -143,6 +293,50 @@ class MemoryDecisionAgent:
)
return items
def decide_conversation_write_decision(
self,
*,
user_text: str,
assistant_text: str,
interrupted: bool,
) -> MemoryWriteDecision:
if interrupted or not assistant_text.strip():
return MemoryWriteDecision("reject", reason="interrupted_or_empty_assistant")
text = user_text.strip()
if text.lower() in _LOW_VALUE_INPUTS:
return MemoryWriteDecision("reject", reason="low_value")
if not self._base_valid(text):
return MemoryWriteDecision("reject", reason="invalid")
if _SENSITIVE_RE.search(text):
return MemoryWriteDecision("reject", reason="sensitive")
if _MEMORY_CHECK_QUESTION_RE.search(text):
return MemoryWriteDecision("reject", reason="memory_check_question")
if _NAMED_ENTITY_QUESTION_RE.search(text) or _RECALL_WRITE_QUESTION_RE.search(text):
return MemoryWriteDecision("reject", reason="recall_question")
item = MemoryItem(
id="",
text=text,
type="chat_turn",
metadata={
"role": "user",
"source": "session",
"source_type": "realtime_turn",
"category": "mem0_candidate",
"confidence": "unknown",
"write_action": "mem0_infer",
"decision_reason": "needs_smart_judgement",
},
created_at=utc_now_iso(),
)
return MemoryWriteDecision(
"mem0_infer",
category="mem0_candidate",
confidence="unknown",
reason="needs_smart_judgement",
items=[item],
)
def decide_conversation_write(
self,
*,
@@ -150,21 +344,11 @@ class MemoryDecisionAgent:
assistant_text: str,
interrupted: bool,
) -> list[MemoryItem]:
if interrupted or not assistant_text.strip():
return []
text = user_text.strip()
if not self._should_store_realtime(text):
return []
kind = self._classify(text)
return [
MemoryItem(
id="",
text=text,
type=kind,
metadata={"role": "user", "source": "session"},
created_at=utc_now_iso(),
)
]
return self.decide_conversation_write_decision(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
).items
def _base_valid(self, text: str) -> bool:
stripped = text.strip()
@@ -186,11 +370,156 @@ class MemoryDecisionAgent:
return False
return self._looks_like_preference(stripped)
def _classify(self, text: str):
if self._looks_like_preference(text):
def _classify(self, text: str, *, category: str | None = None):
if category == "user_preference" or self._looks_like_preference(text):
return "preference"
return "fact"
def _looks_like_preference(self, text: str) -> bool:
lower = text.lower()
return any(marker in lower for marker in _PREFERENCE_MARKERS)
def _classify_write_candidate(self, text: str, *, import_mode: bool) -> tuple[str, str, str]:
stripped = text.strip()
if _FEEDBACK_CORRECTION_RE.search(stripped):
return "feedback_correction", "high", "feedback_correction"
if _ENTITY_RELATION_RE.search(stripped):
return "entity_relation", "high", "entity_relation"
if _GOAL_PROGRESS_RE.search(stripped):
return "goal_progress", "high", "goal_progress"
if _GOAL_INTENT_RE.search(stripped):
return "goal_progress", "high", "goal_context"
if _DECISION_PLAN_RE.search(stripped):
return "decision_plan", "high", "decision_plan"
if self._looks_like_preference(stripped):
return "user_preference", "high", "preference_marker"
if _MEDIUM_MEMORY_RE.search(stripped):
return "goal_progress", "medium", "medium_durable_context"
if _SUMMARY_VALUE_RE.search(stripped):
return "episode_summary", "low", "summary_context"
if import_mode and len(stripped) >= 8:
return "entity_relation", "medium", "import_context"
return "reject", "", "no_marker"
def canonical_relation_correction_memory(
*,
current_text: str,
context_turns: Sequence[dict[str, str]],
) -> MemoryItem | None:
correction = _RELATION_CORRECTION_RE.search(current_text.strip())
if not correction:
return None
old_relation = correction.group("old")
new_relation = correction.group("new")
for turn in reversed(list(context_turns)):
if (turn.get("role") or "").strip().lower() != "user":
continue
declaration = _RELATION_DECLARATION_RE.search((turn.get("content") or "").strip())
if not declaration:
continue
if declaration.group("relation") != old_relation:
continue
name = declaration.group("name").strip(",。!?,.!? ")
if not name:
continue
return MemoryItem(
id="",
text=f"{name}是用户的{new_relation}",
type="fact",
metadata={
"role": "user",
"source": "session",
"source_type": "realtime_turn",
"category": "entity_relation",
"confidence": "high",
"write_action": "direct_write",
"decision_reason": "relation_correction_context",
},
created_at=utc_now_iso(),
)
return None
def needs_recent_context_for_smart_write(user_text: str) -> bool:
text = (user_text or "").strip()
return bool(
_FEEDBACK_CORRECTION_RE.search(text)
or _ASSISTANT_CONTEXT_CONFIRMATION_RE.search(text)
)
def should_include_assistant_context_for_smart_write(user_text: str) -> bool:
return bool(_ASSISTANT_CONTEXT_CONFIRMATION_RE.search((user_text or "").strip()))
def _extract_json_object(text: str) -> dict[str, Any]:
start = text.find("{")
end = text.rfind("}")
if start < 0 or end <= start:
return {}
try:
loaded = json.loads(text[start : end + 1])
except json.JSONDecodeError:
return {}
return loaded if isinstance(loaded, dict) else {}
class MemoryLLMRecallJudge:
"""Second-stage recall judge for ambiguous inputs.
The rule-based agent remains the first gate. This judge is only invoked by
MemoryRuntime when configuration allows it and the rule result is ambiguous.
"""
def __init__(self, settings: Any) -> None:
self.settings = settings
async def decide_recall(self, user_text: str) -> RecallDecision:
if not getattr(self.settings, "llm_base_url", ""):
return RecallDecision(False, reason="llm_unavailable")
from opentalking.providers.llm.openai_compatible.adapter import OpenAICompatibleLLMClient
client = OpenAICompatibleLLMClient(
base_url=self.settings.llm_base_url,
api_key=self.settings.llm_api_key,
model=self.settings.llm_model,
)
messages = [
{
"role": "system",
"content": (
"You decide whether a user message needs long-term memory recall. "
"Return JSON only with keys: should_recall, query, reason, categories. "
"Recall only for references to the user's identity, preferences, "
"past decisions, goals, important people/entities, prior context, "
"stable facts, or interaction feedback. "
"Treat unresolved style/preference references as recall-worthy, "
"for example: 那套风格, 那个方式, 按之前那样, usual style, same way. "
"For digital-human conversations, recall when comfort, learning, coaching, "
"shopping, or role-interaction replies need user preferences, goals, "
"relationships, plans, or feedback corrections. "
"Do not recall for general knowledge requests, greetings, or one-off commands."
),
},
{
"role": "user",
"content": user_text.strip(),
},
]
chunks: list[str] = []
async for chunk in client.chat_stream(messages):
chunks.append(chunk)
parsed = _extract_json_object("".join(chunks).strip())
should_recall = bool(parsed.get("should_recall"))
query = str(parsed.get("query") or user_text).strip()
reason = str(parsed.get("reason") or "llm_decision").strip()
raw_categories = parsed.get("categories") or ()
if isinstance(raw_categories, str):
categories = (raw_categories,)
elif isinstance(raw_categories, list):
categories = tuple(str(item) for item in raw_categories if str(item).strip())
else:
categories = ()
return RecallDecision(should_recall, query=query, reason=reason, categories=categories)

View File

@@ -10,14 +10,92 @@ from opentalking.providers.memory.noop import NoopMemoryProvider
from opentalking.providers.memory.sqlite_provider import SQLiteMemoryProvider
def _strip(value: Any) -> str:
return str(value or "").strip()
def _base_url_key(provider: str) -> str:
return "openai_base_url" if provider.lower() == "openai" else "base_url"
def _model_config(*, provider: str, model: str, api_key: str, base_url: str) -> dict[str, Any]:
cleaned_provider = _strip(provider)
config: dict[str, Any] = {}
if _strip(model):
config["model"] = _strip(model)
if _strip(api_key):
config["api_key"] = _strip(api_key)
if _strip(base_url):
config[_base_url_key(cleaned_provider)] = _strip(base_url)
if not cleaned_provider and not config:
return {}
return {"provider": cleaned_provider or "openai", "config": config}
def _normalize_vector_store(config: dict[str, Any]) -> dict[str, Any]:
vector_store = config.get("vector_store")
if isinstance(vector_store, dict) and str(vector_store.get("provider") or "").lower() == "qdrant":
store_config = vector_store.get("config")
if isinstance(store_config, dict) and store_config.get("path") and "on_disk" not in store_config:
store_config["on_disk"] = True
return config
def _split_mem0_config(settings: Settings) -> dict[str, Any]:
config: dict[str, Any] = {}
llm = _model_config(
provider=settings.memory_mem0_llm_provider,
model=settings.memory_mem0_llm_model,
api_key=settings.memory_mem0_llm_api_key,
base_url=settings.memory_mem0_llm_base_url,
)
if llm:
config["llm"] = llm
embedder = _model_config(
provider=settings.memory_mem0_embedder_provider,
model=settings.memory_mem0_embedder_model,
api_key=settings.memory_mem0_embedder_api_key,
base_url=settings.memory_mem0_embedder_base_url,
)
if embedder:
embedder_config = embedder.setdefault("config", {})
dims = int(getattr(settings, "memory_mem0_embedder_embedding_dims", 0) or 0)
if dims > 0:
embedder_config["embedding_dims"] = dims
config["embedder"] = embedder
vector_provider = _strip(settings.memory_mem0_vector_store_provider)
if vector_provider:
store_config: dict[str, Any] = {}
if _strip(settings.memory_mem0_vector_store_collection_name):
store_config["collection_name"] = _strip(settings.memory_mem0_vector_store_collection_name)
if _strip(settings.memory_mem0_vector_store_path):
store_config["path"] = _strip(settings.memory_mem0_vector_store_path)
if _strip(settings.memory_mem0_vector_store_host):
store_config["host"] = _strip(settings.memory_mem0_vector_store_host)
port = int(getattr(settings, "memory_mem0_vector_store_port", 0) or 0)
if port > 0:
store_config["port"] = port
dims = int(getattr(settings, "memory_mem0_vector_store_embedding_model_dims", 0) or 0)
if dims > 0:
store_config["embedding_model_dims"] = dims
config["vector_store"] = {"provider": vector_provider, "config": store_config}
return _normalize_vector_store(config)
def _mem0_config(settings: Settings) -> dict[str, Any]:
raw = (settings.memory_mem0_config or "").strip()
if not raw:
return {}
import json
if raw:
import json
loaded = json.loads(raw)
return loaded if isinstance(loaded, dict) else {}
loaded = json.loads(raw)
if not isinstance(loaded, dict):
return {}
return _normalize_vector_store(loaded)
return _split_mem0_config(settings)
@lru_cache(maxsize=1)

View File

@@ -2,13 +2,49 @@ from __future__ import annotations
import asyncio
import inspect
import logging
import uuid
from collections.abc import Sequence
from contextlib import contextmanager
from typing import Any
from opentalking.providers.memory.base import MemoryProvider
from opentalking.providers.memory.schemas import MemoryItem, MemoryLibrary, utc_now_iso
log = logging.getLogger(__name__)
class _Mem0RawLogFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
if record.name != "root":
return True
message = record.getMessage()
if message.startswith(
(
"Creating memory with data=",
"NOOP for Memory",
"Total existing memories:",
"Deleting memory with memory_id=",
)
):
return False
if "'event':" in message and ("'text':" in message or "'memory':" in message):
return False
return True
_MEM0_RAW_LOG_FILTER = _Mem0RawLogFilter()
@contextmanager
def _suppress_mem0_raw_logs():
root_logger = logging.getLogger()
root_logger.addFilter(_MEM0_RAW_LOG_FILTER)
try:
yield
finally:
root_logger.removeFilter(_MEM0_RAW_LOG_FILTER)
class Mem0UnavailableError(RuntimeError):
pass
@@ -28,6 +64,50 @@ async def _maybe_await(value: Any) -> Any:
return value
def _supports_kwarg(func: Any, name: str) -> bool:
try:
signature = inspect.signature(func)
except (TypeError, ValueError):
return True
return any(
param.kind == inspect.Parameter.VAR_KEYWORD or param.name == name
for param in signature.parameters.values()
)
def _count_add_entry(entry: Any) -> int:
if not isinstance(entry, dict):
return 1 if entry else 0
event = str(entry.get("event") or "").strip().upper()
if event in {"NONE", "NOOP", "SKIP", "SKIPPED"}:
return 0
if event:
return 1
return 1 if (entry.get("memory") or entry.get("text") or entry.get("content") or entry.get("id")) else 0
def _count_add_result(raw: Any) -> int:
if raw is None:
return 1
if isinstance(raw, list):
return sum(_count_add_entry(entry) for entry in raw)
if isinstance(raw, dict):
results = raw.get("results")
if isinstance(results, list):
return sum(_count_add_entry(entry) for entry in results)
if isinstance(results, dict):
return sum(
_count_add_entry(entry)
for value in results.values()
if isinstance(value, list)
for entry in value
)
if "event" in raw:
return _count_add_entry(raw)
return 1 if raw else 0
return 1 if raw else 0
class Mem0MemoryProvider(MemoryProvider):
def __init__(
self,
@@ -172,10 +252,116 @@ class Mem0MemoryProvider(MemoryProvider):
"created_at": item.created_at or utc_now_iso(),
}
)
await self._add(text, profile_id=profile_id, character_id=character_id, metadata=metadata)
imported += 1
imported += await self._add(
text,
profile_id=profile_id,
character_id=character_id,
metadata=metadata,
infer=False,
)
return imported
async def search_items(
self,
*,
query: str,
library_id: str,
profile_id: str,
character_id: str,
limit: int,
) -> list[MemoryItem]:
search = getattr(self._client, "search", None)
if not callable(search):
return []
kwargs = {
"user_id": profile_id,
"agent_id": character_id,
"limit": max(0, int(limit)),
}
try:
with _suppress_mem0_raw_logs():
raw = await _maybe_await(search(query, **kwargs))
except TypeError:
with _suppress_mem0_raw_logs():
raw = await _maybe_await(search(query=query, **kwargs))
items = self._normalize_raw_items(raw)
return [
item
for item in items
if item.metadata.get("library_id") == library_id
and item.metadata.get("profile_id", profile_id) == profile_id
and item.metadata.get("character_id", character_id) == character_id
][: max(0, int(limit))]
async def add_conversation_turns(
self,
*,
library_id: str,
profile_id: str,
character_id: str,
turns: Sequence[dict[str, str]],
include_assistant_context: bool = False,
metadata: dict[str, Any] | None = None,
) -> int:
messages = [
{"role": str(turn.get("role") or "").strip(), "content": str(turn.get("content") or "").strip()}
for turn in turns
if str(turn.get("role") or "").strip()
and str(turn.get("content") or "").strip()
and (include_assistant_context or str(turn.get("role") or "").strip().lower() == "user")
]
if not messages:
return 0
merged_metadata = {
"library_id": library_id,
"profile_id": profile_id,
"character_id": character_id,
"type": "chat_turn",
"source": "session",
"source_type": "conversation_turn",
"created_at": utc_now_iso(),
}
merged_metadata.update(metadata or {})
return await self._add(
messages,
profile_id=profile_id,
character_id=character_id,
metadata=merged_metadata,
infer=True,
)
async def add_summary(
self,
*,
library_id: str,
profile_id: str,
character_id: str,
summary: str,
metadata: dict[str, Any] | None = None,
) -> int:
text = summary.strip()
if not text:
return 0
merged_metadata = {
"library_id": library_id,
"profile_id": profile_id,
"character_id": character_id,
"type": "summary",
"source": "session",
"source_type": "session_summary",
"layer": "episodic",
"opentalking_memory_id": f"summary_{uuid.uuid4().hex}",
"created_at": utc_now_iso(),
}
merged_metadata.update(metadata or {})
return await self._add(
text,
profile_id=profile_id,
character_id=character_id,
metadata=merged_metadata,
infer=False,
)
async def delete_item(
self,
*,
@@ -196,7 +382,8 @@ class Mem0MemoryProvider(MemoryProvider):
if not callable(delete):
return False
raw_id = item.metadata.get("_mem0_id") or item.id
await _maybe_await(delete(raw_id))
with _suppress_mem0_raw_logs():
await _maybe_await(delete(raw_id))
return True
async def close(self) -> None:
@@ -206,12 +393,21 @@ class Mem0MemoryProvider(MemoryProvider):
async def _add(
self,
text: str,
payload: Any,
*,
profile_id: str,
character_id: str,
metadata: dict[str, Any],
) -> None:
infer: bool = False,
) -> int:
if isinstance(payload, str) and not infer and await self._create_raw_memory(
payload,
profile_id=profile_id,
character_id=character_id,
metadata=metadata,
):
return 1
add = getattr(self._client, "add", None)
if not callable(add):
raise RuntimeError("mem0 client does not expose add()")
@@ -220,22 +416,61 @@ class Mem0MemoryProvider(MemoryProvider):
"user_id": profile_id,
"agent_id": character_id,
"metadata": metadata,
"infer": False,
}
if _supports_kwarg(add, "infer"):
kwargs["infer"] = infer
if isinstance(payload, str):
payloads = [[{"role": "user", "content": payload}], payload]
else:
payloads = [payload]
last_error: TypeError | None = None
for candidate in payloads:
try:
with _suppress_mem0_raw_logs():
raw = await _maybe_await(add(candidate, **kwargs))
count = _count_add_result(raw)
if count > 0:
return count
except TypeError as exc:
last_error = exc
if last_error is not None:
raise last_error
return 0
async def _create_raw_memory(
self,
text: str,
*,
profile_id: str,
character_id: str,
metadata: dict[str, Any],
) -> bool:
create_memory = getattr(self._client, "_create_memory", None)
if not callable(create_memory):
return False
raw_metadata = dict(metadata)
raw_metadata.setdefault("user_id", profile_id)
raw_metadata.setdefault("agent_id", character_id)
try:
await _maybe_await(add(text, **kwargs))
except TypeError:
messages = [{"role": "user", "content": text}]
await _maybe_await(add(messages, **kwargs))
with _suppress_mem0_raw_logs():
await _maybe_await(create_memory(text, {}, metadata=raw_metadata))
except Exception: # noqa: BLE001
log.warning("mem0 direct raw memory create failed; falling back to add()", exc_info=True)
return False
return True
async def _all_scoped_items(self, *, profile_id: str, character_id: str) -> list[MemoryItem]:
get_all = getattr(self._client, "get_all", None)
if not callable(get_all):
return []
try:
raw = await _maybe_await(get_all(user_id=profile_id, agent_id=character_id))
with _suppress_mem0_raw_logs():
raw = await _maybe_await(get_all(user_id=profile_id, agent_id=character_id))
except TypeError:
raw = await _maybe_await(get_all(user_id=profile_id))
with _suppress_mem0_raw_logs():
raw = await _maybe_await(get_all(user_id=profile_id))
return [
item
for item in self._normalize_raw_items(raw)

View File

@@ -3,17 +3,63 @@ from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import Any
from opentalking.core.config import Settings, get_settings
from opentalking.providers.memory.base import MemoryProvider
from opentalking.providers.memory.bm25 import memories_to_prompt, rank_items_bm25
from opentalking.providers.memory.decision_agent import MemoryDecisionAgent
from opentalking.providers.memory.decision_agent import (
MemoryDecisionAgent,
MemoryLLMRecallJudge,
MemoryWriteDecision,
RecallDecision,
canonical_relation_correction_memory,
needs_recent_context_for_smart_write,
should_include_assistant_context_for_smart_write,
)
from opentalking.providers.memory.factory import build_memory_provider
from opentalking.providers.memory.schemas import MemoryItem
log = logging.getLogger(__name__)
def _settings_choice(settings: Settings, field: str, default: str, allowed: set[str]) -> str:
value = str(getattr(settings, field, default) or default).strip().lower()
return value if value in allowed else default
def _memory_recall_backend(settings: Settings) -> str:
return _settings_choice(settings, "memory_recall_backend", "hybrid", {"bm25", "mem0", "hybrid"})
def _memory_write_mode(settings: Settings) -> str:
return _settings_choice(settings, "memory_write_mode", "hybrid", {"raw", "mem0", "hybrid"})
def _memory_decision_mode(settings: Settings) -> str:
return _settings_choice(settings, "memory_decision_mode", "rule", {"rule", "hybrid", "llm"})
def _memory_decision_timeout_ms(settings: Settings) -> int:
return max(1, int(getattr(settings, "memory_decision_timeout_ms", 800) or 800))
def _memory_smart_write_enabled(settings: Settings) -> bool:
return bool(getattr(settings, "memory_smart_write_enabled", True))
def _memory_summary_enabled(settings: Settings) -> bool:
return bool(getattr(settings, "memory_summary_enabled", False))
def _memory_summary_turn_window(settings: Settings) -> int:
return max(1, int(getattr(settings, "memory_summary_turn_window", 8) or 8))
def _memory_summary_max_items(settings: Settings) -> int:
return max(1, int(getattr(settings, "memory_summary_max_items", 3) or 3))
@dataclass(frozen=True)
class MemoryScope:
enabled: bool
@@ -45,6 +91,74 @@ def normalize_memory_scope(
)
class MemorySummaryAgent:
def __init__(self, settings: Settings) -> None:
self.settings = settings
async def summarize(self, turns: list[dict[str, str]], *, max_items: int) -> str:
if not turns:
return ""
if self.settings.llm_base_url:
try:
summary = await self._summarize_with_llm(turns, max_items=max_items)
except Exception: # noqa: BLE001
log.warning("memory summary LLM call failed; using local fallback", exc_info=True)
else:
if summary.strip():
return summary.strip()
return self._fallback_summary(turns, max_items=max_items)
async def _summarize_with_llm(self, turns: list[dict[str, str]], *, max_items: int) -> str:
from opentalking.providers.llm.openai_compatible.adapter import OpenAICompatibleLLMClient
client = OpenAICompatibleLLMClient(
base_url=self.settings.llm_base_url,
api_key=self.settings.llm_api_key,
model=self.settings.llm_model,
)
transcript = "\n".join(
f"{turn.get('role', '').strip()}: {turn.get('content', '').strip()}"
for turn in turns
if turn.get("content", "").strip()
)
messages = [
{
"role": "system",
"content": (
"You summarize digital-human chat history into durable user memories. "
"Return concise Chinese bullet-style facts only. Focus on user preferences, "
"important people/entities, confirmed decisions or plans, goals and progress, "
"and feedback or corrections about the interaction style."
),
},
{
"role": "user",
"content": (
f"Extract at most {max_items} durable memories from this conversation. "
"Ignore greetings, transient wording, one-off requests, and assistant-only facts.\n\n"
f"{transcript}"
),
},
]
chunks: list[str] = []
async for chunk in client.chat_stream(messages):
chunks.append(chunk)
return "".join(chunks).strip()
def _fallback_summary(self, turns: list[dict[str, str]], *, max_items: int) -> str:
snippets: list[str] = []
for turn in turns:
if turn.get("role") != "user":
continue
content = turn.get("content", "").strip()
if not content:
continue
snippets.append(content)
if len(snippets) >= max_items:
break
return "".join(snippets)
class MemoryRuntime:
def __init__(
self,
@@ -53,12 +167,18 @@ class MemoryRuntime:
provider: MemoryProvider | None = None,
decision_agent: MemoryDecisionAgent | None = None,
settings: Settings | None = None,
summary_agent: Any | None = None,
decision_judge: Any | None = None,
) -> None:
self.scope = scope
self.provider = provider or build_memory_provider()
self.decision_agent = decision_agent or MemoryDecisionAgent()
self.settings = settings or get_settings()
self.summary_agent = summary_agent or MemorySummaryAgent(self.settings)
self.decision_judge = decision_judge or MemoryLLMRecallJudge(self.settings)
self._write_tasks: set[asyncio.Task[None]] = set()
self._summary_turn_buffer: list[dict[str, str]] = []
self._recent_turn_buffer: list[dict[str, str]] = []
@property
def enabled(self) -> bool:
@@ -67,9 +187,87 @@ class MemoryRuntime:
async def retrieve_prompt(self, query: str) -> str:
if not self.enabled:
return ""
decision = self.decision_agent.decide_recall(query)
decision = await self._decide_recall(query)
log.info(
"memory.recall decision should=%s reason=%s categories=%s",
decision.should_recall,
decision.reason,
",".join(decision.categories),
)
if not decision.should_recall:
return ""
recall_query = decision.query or query
backend = _memory_recall_backend(self.settings)
if backend in {"mem0", "hybrid"}:
searched = await self._search_items(recall_query)
if searched:
log.info("memory.recall injected count=%d backend=mem0", len(searched))
return memories_to_prompt(searched)
if backend == "mem0":
return ""
return await self._retrieve_bm25_prompt(recall_query)
async def _decide_recall(self, query: str) -> RecallDecision:
rule_decision = self.decision_agent.decide_recall(query)
mode = _memory_decision_mode(self.settings)
if mode == "rule":
return rule_decision
if rule_decision.reason in {"empty", "low_value", "high_risk_ignored"}:
return rule_decision
if mode == "hybrid" and rule_decision.reason != "no_marker":
return rule_decision
judged = await self._judge_recall(query)
return judged if judged is not None else rule_decision
async def _judge_recall(self, query: str) -> RecallDecision | None:
decide_recall = getattr(self.decision_judge, "decide_recall", None)
if not callable(decide_recall):
return None
try:
decision = await asyncio.wait_for(
decide_recall(query),
timeout=_memory_decision_timeout_ms(self.settings) / 1000.0,
)
except TimeoutError:
log.warning("memory decision judge timed out")
return None
except Exception: # noqa: BLE001
log.warning("memory decision judge failed", exc_info=True)
return None
if not isinstance(decision, RecallDecision):
return None
if decision.should_recall and not decision.query.strip():
return RecallDecision(
True,
query=query,
reason=decision.reason or "llm_decision",
categories=decision.categories,
)
return decision
async def _search_items(self, query: str) -> list[MemoryItem] | None:
search_items = getattr(self.provider, "search_items", None)
if not callable(search_items):
return None
try:
return await asyncio.wait_for(
search_items(
query=query,
library_id=self.scope.library_id,
profile_id=self.scope.profile_id,
character_id=self.scope.character_id,
limit=max(0, int(self.settings.memory_recall_limit)),
),
timeout=max(0.001, float(self.settings.memory_recall_timeout_ms) / 1000.0),
)
except TimeoutError:
log.warning("memory search timed out")
return None
except Exception: # noqa: BLE001
log.warning("memory search failed", exc_info=True)
return None
async def _retrieve_bm25_prompt(self, query: str) -> str:
try:
candidates = await asyncio.wait_for(
self.provider.list_items(
@@ -86,11 +284,12 @@ class MemoryRuntime:
log.warning("memory retrieval failed", exc_info=True)
return ""
ranked = rank_items_bm25(
decision.query or query,
query,
candidates,
limit=max(0, int(self.settings.memory_recall_limit)),
min_score=float(self.settings.memory_recall_min_score),
)
log.info("memory.recall injected count=%d backend=bm25", len(ranked))
return memories_to_prompt(ranked)
def schedule_write(
@@ -102,16 +301,78 @@ class MemoryRuntime:
) -> None:
if not self.enabled:
return
items = self.decision_agent.decide_conversation_write(
decision = self.decision_agent.decide_conversation_write_decision(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
)
if not items:
log.info(
"memory.write decision action=%s category=%s confidence=%s reason=%s",
decision.action,
decision.category,
decision.confidence,
decision.reason,
)
summary_buffered = False
if _memory_summary_enabled(self.settings) and self._should_buffer_summary(decision):
summary_buffered = self._buffer_summary_turn(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
)
if decision.action == "reject":
self._remember_recent_turn(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
)
return
task = asyncio.create_task(self._write_items(items))
self._write_tasks.add(task)
task.add_done_callback(self._write_tasks.discard)
if _memory_summary_enabled(self.settings) and not summary_buffered:
self._buffer_summary_turn(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
)
if decision.action == "summary_only":
self._remember_recent_turn(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
)
return
if decision.action == "direct_write":
if decision.items:
task = asyncio.create_task(self._write_items(decision.items))
self._track_task(task)
self._remember_recent_turn(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
)
return
if not decision.items:
self._remember_recent_turn(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
)
return
context_turns = self._smart_write_context(user_text=user_text)
task = asyncio.create_task(
self._write_conversation_turn(
user_text=user_text,
assistant_text=assistant_text,
decision=decision,
context_turns=context_turns,
)
)
self._track_task(task)
self._remember_recent_turn(
user_text=user_text,
assistant_text=assistant_text,
interrupted=interrupted,
)
async def import_turns(
self,
@@ -131,17 +392,198 @@ class MemoryRuntime:
items=items,
)
def _track_task(self, task: asyncio.Task[None]) -> None:
self._write_tasks.add(task)
task.add_done_callback(self._write_tasks.discard)
def _should_buffer_summary(self, decision: MemoryWriteDecision) -> bool:
return decision.reason not in {
"interrupted_or_empty_assistant",
"invalid",
"low_value",
"sensitive",
"memory_check_question",
"recall_question",
}
def _buffer_summary_turn(
self,
*,
user_text: str,
assistant_text: str,
interrupted: bool,
) -> bool:
user = user_text.strip()
assistant = assistant_text.strip()
if interrupted or not user or not assistant:
return False
self._summary_turn_buffer.extend(
[
{"role": "user", "content": user},
{"role": "assistant", "content": assistant},
]
)
turn_count = sum(1 for turn in self._summary_turn_buffer if turn.get("role") == "user")
log.info(
"memory.summary buffered turns=%d window=%d",
turn_count,
_memory_summary_turn_window(self.settings),
)
if turn_count < _memory_summary_turn_window(self.settings):
return True
turns = list(self._summary_turn_buffer)
self._summary_turn_buffer.clear()
task = asyncio.create_task(self._write_summary(turns=turns, turn_count=turn_count))
self._track_task(task)
return True
def _remember_recent_turn(
self,
*,
user_text: str,
assistant_text: str,
interrupted: bool,
) -> None:
user = user_text.strip()
assistant = assistant_text.strip()
if interrupted or not user or not assistant:
return
self._recent_turn_buffer.extend(
[
{"role": "user", "content": user},
{"role": "assistant", "content": assistant},
]
)
max_turns = max(2, _memory_summary_turn_window(self.settings) * 2)
if len(self._recent_turn_buffer) > max_turns:
self._recent_turn_buffer = self._recent_turn_buffer[-max_turns:]
def _smart_write_context(self, *, user_text: str) -> list[dict[str, str]]:
if not needs_recent_context_for_smart_write(user_text):
return []
return list(self._recent_turn_buffer)
async def _write_conversation_turn(
self,
*,
user_text: str,
assistant_text: str,
decision: MemoryWriteDecision,
context_turns: list[dict[str, str]],
) -> None:
write_mode = _memory_write_mode(self.settings)
should_try_smart = _memory_smart_write_enabled(self.settings) and write_mode in {
"mem0",
"hybrid",
}
allow_raw_fallback = decision.confidence != "medium"
canonical_item = canonical_relation_correction_memory(
current_text=user_text,
context_turns=context_turns,
)
if should_try_smart:
add_conversation_turns = getattr(self.provider, "add_conversation_turns", None)
if callable(add_conversation_turns):
try:
turns = [
*context_turns,
{"role": "user", "content": user_text.strip()},
]
include_assistant_context = should_include_assistant_context_for_smart_write(user_text)
if include_assistant_context:
turns.append({"role": "assistant", "content": assistant_text.strip()})
stored = await add_conversation_turns(
library_id=self.scope.library_id,
profile_id=self.scope.profile_id,
character_id=self.scope.character_id,
turns=turns,
include_assistant_context=include_assistant_context,
metadata={
"category": decision.category,
"confidence": decision.confidence,
"decision_reason": decision.reason,
"write_action": decision.action,
},
)
log.info(
"memory.write stored count=%s provider=mem0 category=%s confidence=%s",
stored,
decision.category,
decision.confidence,
)
if canonical_item is not None:
await self._write_items([canonical_item])
return
if stored > 0:
return
if decision.category == "mem0_candidate":
return
if write_mode != "hybrid" or not allow_raw_fallback:
return
except Exception: # noqa: BLE001
log.warning("memory smart write failed", exc_info=True)
if decision.category == "mem0_candidate":
return
if write_mode != "hybrid" or not allow_raw_fallback:
return
elif write_mode != "hybrid":
return
await self._write_items([canonical_item] if canonical_item is not None else decision.items)
async def _write_items(self, items: list[MemoryItem]) -> None:
try:
await self.provider.add_items(
stored = await self.provider.add_items(
library_id=self.scope.library_id,
profile_id=self.scope.profile_id,
character_id=self.scope.character_id,
items=items,
)
log.info("memory.write stored count=%s provider=raw", stored)
except Exception: # noqa: BLE001
log.warning("memory write failed", exc_info=True)
async def _write_summary(self, *, turns: list[dict[str, str]], turn_count: int) -> None:
try:
summary = await self.summary_agent.summarize(
turns,
max_items=_memory_summary_max_items(self.settings),
)
if not summary.strip():
return
metadata = {
"source_type": "session_summary",
"layer": "episodic",
"category": "episode_summary",
"turn_count": turn_count,
}
add_summary = getattr(self.provider, "add_summary", None)
if callable(add_summary):
stored = await add_summary(
library_id=self.scope.library_id,
profile_id=self.scope.profile_id,
character_id=self.scope.character_id,
summary=summary,
metadata=metadata,
)
log.info("memory.summary stored count=%s", stored)
return
stored = await self.provider.add_items(
library_id=self.scope.library_id,
profile_id=self.scope.profile_id,
character_id=self.scope.character_id,
items=[
MemoryItem(
id="",
text=summary,
type="summary",
metadata=metadata,
)
],
)
log.info("memory.summary stored count=%s", stored)
except Exception: # noqa: BLE001
log.warning("memory summary write failed", exc_info=True)
async def drain(self) -> None:
tasks = [task for task in self._write_tasks if not task.done()]
if tasks:

View File

@@ -48,6 +48,30 @@ quickstart_describe_uv_default_index() {
printf '%s\n' "${UV_DEFAULT_INDEX:-${UV_INDEX_URL:-default}}"
}
quickstart_configure_utf8() {
local locale_name="${OPENTALKING_QUICKSTART_LOCALE:-}"
if [[ -z "$locale_name" ]]; then
if locale -a 2>/dev/null | grep -Eiq '^(C|c)\.(UTF-8|utf8)$'; then
locale_name="C.UTF-8"
else
locale_name="en_US.UTF-8"
fi
fi
export LANG="$locale_name"
export LC_ALL="$locale_name"
export PYTHONIOENCODING="${PYTHONIOENCODING:-utf-8}"
export PYTHONUTF8="${PYTHONUTF8:-1}"
export TERM="${OPENTALKING_QUICKSTART_TERM:-dumb}"
export NO_COLOR="${NO_COLOR:-1}"
export CLICOLOR="${CLICOLOR:-0}"
export FORCE_COLOR="${FORCE_COLOR:-0}"
export PY_COLORS="${PY_COLORS:-0}"
export TQDM_DISABLE="${TQDM_DISABLE:-1}"
export HF_HUB_DISABLE_PROGRESS_BARS="${HF_HUB_DISABLE_PROGRESS_BARS:-1}"
}
quickstart_source_env() {
local env_file="$1"
local restore_allexport=1

View File

@@ -25,6 +25,20 @@
# OPENTALKING_WEB_PORT=5173
# OPENTALKING_WEB_HOST=0.0.0.0
# Character memory / Mem0. Sessions/personas must also turn on memory_enabled.
# This adapter uses mem0.Memory, so MEM0_API_KEY is not required.
# OPENTALKING_MEMORY_SUMMARY_ENABLED=true
# OPENTALKING_MEMORY_SUMMARY_TURN_WINDOW=8
# OPENTALKING_MEMORY_SUMMARY_MAX_ITEMS=3
# OPENTALKING_MEMORY_MEM0_LLM_PROVIDER=openai
# OPENTALKING_MEMORY_MEM0_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# OPENTALKING_MEMORY_MEM0_LLM_API_KEY=<llm-api-key>
# OPENTALKING_MEMORY_MEM0_LLM_MODEL=qwen-flash
# OPENTALKING_MEMORY_MEM0_EMBEDDER_PROVIDER=openai
# OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY=<embedding-api-key>
# OPENTALKING_MEMORY_MEM0_EMBEDDER_MODEL=text-embedding-v4
# Real model serving endpoint. Leave empty for mock-only startup.
# OMNIRT_ENDPOINT=http://127.0.0.1:9000
# OMNIRT_AUDIO2VIDEO_PATH_TEMPLATE=/v1/audio2video/{model}

View File

@@ -9,6 +9,7 @@ source "$script_dir/_helpers.sh"
env_file="${OPENTALKING_QUICKSTART_ENV:-$script_dir/env}"
quickstart_source_env "$env_file"
quickstart_configure_utf8
usage() {
cat <<'USAGE'

View File

@@ -9,6 +9,7 @@ source "$script_dir/_helpers.sh"
env_file="${OPENTALKING_QUICKSTART_ENV:-$script_dir/env}"
quickstart_source_env "$env_file"
quickstart_configure_utf8
usage() {
cat <<'USAGE'

File diff suppressed because it is too large Load Diff