Files
Hy-Memory/hy_memory/client.py
T
alvinfeiandClaude Opus 5.5 3772a69c6b feat(sdk): store raw conversations already trimmed; history concatenates without re-trimming
Raw is kept forever (MemHub dropped its retention; lite stores nothing else),
so trim it once when it is written instead of on every history read.

- hy_memory/utils/raw_trim.py: one idempotent rule per speaker label.
  user/system kept; assistant and other speakers cut to 500 chars + "...";
  tool head/tail with coding.tool_result_max_bytes (default 2048).
- writer: L1_RAW (lite/pro/ultra) is flattened from trimmed messages and
  flagged custom.raw_trimmed; request.messages (extractor input) untouched.
- _get_recent_history concatenates flagged raws as stored; legacy unflagged
  raws are trimmed with the same rule on read.
- client: memory_history ADD record uses the same trimmed messages.
- coding preproc: tool/assistant truncation made idempotent so re-trimming
  coding_trace history no longer rewrites omitted-count markers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 18:02:40 +08:00

7743 lines
337 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
HY Memory Client - 面向用户的同步客户端(无状态)
所有组件在构造函数中完成初始化,失败立即报错。
构造函数只接受连接配置,不绑定任何业务参数(user_id/agent_id 等),
一个 client 实例可被多线程共享。
# 最简用法 — 环境变量配置
client = HyMemoryClient()
client.add("用户喜欢科幻电影", user_id="test_user")
results = client.search("用户喜欢什么?", user_ids=["test_user"])
client.close()
# 自定义配置(类似 mem0 风格)
client = HyMemoryClient.from_config({
"embedder": {"provider": "openai", "model": "text-embedding-3-small"},
"vector_store": {"provider": "qdrant", "collection_name": "my_memories"},
"graph_store": {
"provider": "neo4j",
"url": "bolt://localhost:7687",
"username": "neo4j",
"password": "password",
},
"enable_graph": True,
})
"""
import asyncio
import json
import logging
import os
import threading
import time
import uuid
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple, Union, TYPE_CHECKING
from .config import MemoryConfig
from .core.embed_service import EmbedService, embed_endpoint_override
from .embedding import EmbeddingDimensionMismatchError, EmbeddingService
from .retrieval import AmbiguousMemoryIdError, MemoryKind, MemoryVariant
from .data.vector_store import create_vector_store
from .data.history_store import HistoryStore
from .data.cache import create_cache
from .utils.log_setup import setup_logging, set_request_id, request_id_scope
from .utils.async_executor import (
await_concurrent_future_guarded,
concurrent_result_guarded,
)
if TYPE_CHECKING:
from .runtime import SharedRuntime
from .pipelines import (
ComponentFactory,
ChatMessage,
ToolCall,
WriteRequest,
WriteResponse,
ReadRequest,
ReadResponse,
)
# Productivity memory 模块(含 tool 时三分类为 working/coding 才启用)
from .coding.preproc import has_any_tool_message, strip_tool_messages
from .coding.judge import classify_messages_scene
logger = logging.getLogger(__name__)
# add() 第一个参数的类型:字符串 或 messages 列表
MessageList = List[Dict[str, str]]
AddInput = Union[str, MessageList]
# working(coding)路两层的默认阈值。刻意不复用 chat 路的 min_score:
# chat 匹配的是完整语义单元,0.4 是长期在用的经验值;working 事实层匹配的是一句话
# 结论,同样语义相关度下分数系统性偏低,0.4
# 会把明确相关的事实挡掉。而事实层是主索引,它被挡掉不只是少一条事实:顺
# derived_experience_ids 带经验那一路会跟着断源,双层结构对该 query 直接退化成一次
# 普通经验搜索。所以事实层宁松勿严,噪声交给经验直查那道闸门单独收。
_WORKING_FACT_DEFAULT_MIN_SCORE = 0.30
# 经验直查阈值相对事实层阈值的默认抬升量。
# 两层 embed 的文本形态不同——事实层是整句 statement,经验层是 task + search_keys
# 这类短片段——短片段之间的余弦相似度基线本来就更高,同一个阈值下经验层会明显更松。
# 两层相关/无关的可分区间错开约 0.1,共用一个阈值罩不住。
_WORKING_EXPERIENCE_MIN_SCORE_DELTA = 0.10
# 一次 get_many 最多带多少个 experience_id。SQLite 的 IN 子句有绑定变量上限
# (默认 999),这是防病态长链的安全阀,不是返回条数控制。
_CODING_GET_MANY_MAX_IDS = 500
# search(scene="all") 的 Reciprocal Rank Fusion 常数:item 在本路第 rank 名
# (从 1 起)贡献 1 / (_SCENE_RRF_K + rank)。60 是 RRF 原论文(Cormack 2009)的
# 惯用值:只看名次不看分数,chat 与 working/coding 的分数量纲不同也不会互相压制。
_SCENE_RRF_K = 60
# scene="all" 的三条路,也是同分时的先后顺序。
_SCENE_LANES = ("chat", "working", "coding")
def _epoch_seconds(value: Any, *, naive_is_utc: bool = False) -> Optional[int]:
"""datetime / ISO 字符串 / epoch 数字 → 秒级 epoch;取不到返回 None。"""
stamp = _epoch_float(value, naive_is_utc=naive_is_utc)
return int(stamp) if stamp is not None else None
def _epoch_float(value: Any, *, naive_is_utc: bool = False) -> Optional[float]:
"""同 _epoch_seconds,但保留亚秒精度(排序用,避免同一秒内的先后被抹平)。
naive_is_utc:生产力库的时间戳用 utcnow() 写、不带时区,必须按 UTC 解读,
否则与 chat 侧(本地时间 naive,经 .timestamp() 已是正确 epoch)比较先后时
会差出一个时区偏移。
"""
from datetime import timezone
if value is None or value == "":
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.rstrip("Z"))
except ValueError:
return None
if not isinstance(value, datetime):
return None
if value.tzinfo is None and naive_is_utc:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
_WRITE_DIAGNOSTIC_KEYS = (
"agent_status",
"agent_error_code",
"agent_error",
"l1_rollback",
"l1_rollback_failed",
"partial_agent_persist",
"session_episode",
"workload",
"graph_executor",
)
def _attach_write_diagnostics(resp: Dict[str, Any], extra: Dict[str, Any]) -> None:
"""Expose retry-safety details produced by the write pipeline."""
for key in _WRITE_DIAGNOSTIC_KEYS:
if key in extra:
resp[key] = extra[key]
def _search_memory_channel(
memory: Dict[str, Any],
*,
schema_layers: set[str],
proactive_layers: set[str],
schema_min_score: float,
) -> str:
"""Return the public search channel and consume private routing markers."""
if bool(memory.pop("_schema_channel", False)):
return "schema"
layer = memory.get("layer", "")
if layer in schema_layers:
return "schema" if memory.get("score", 0.0) >= schema_min_score else "normal"
if layer in proactive_layers:
return "proactive"
return "normal"
# cgroup CPU 限流累计时长的文件位置(v2 优先,回退 v1)。
# (路径, 字段名, 除数)——v1 的 throttled_time 单位是纳秒,需 /1000 换成微秒。
_CGROUP_CPU_STAT_SOURCES = (
("/sys/fs/cgroup/cpu.stat", "throttled_usec", 1),
("/sys/fs/cgroup/cpu/cpu.stat", "throttled_time", 1000),
)
def _read_throttled_usec() -> int:
"""读 cgroup 累计被 CPU 限流的微秒数;读不到返回 0。
用于归因进程级停顿:在一段纯同步代码前后各取一次,差值即该段内被容器
强制暂停的时长。容器 CPU quota 打满时,进程会被内核整体挂起,表现为
"纯同步代码耗时几百毫秒"。
同步读小文件(procfs/cgroupfs,无磁盘 IO),微秒级,不阻塞 event loop。
任何异常都静默返回 0——这是可观测性字段,绝不能影响主流程。
注:字段用**精确等值**匹配而非前缀匹配。cgroup v1 的 cpu.stat 里同时存在
`throttled_time` 与 `throttled_time_bt`(burst 统计),前缀匹配会因文件内
字段顺序不同而取错值。
"""
for path, key, div in _CGROUP_CPU_STAT_SOURCES:
try:
with open(path) as f:
for line in f:
parts = line.split()
if len(parts) >= 2 and parts[0] == key:
return int(parts[1]) // div
except Exception:
continue
return 0
def _gc_collection_count() -> int:
"""GC 各代累计回收次数之和;异常返回 0。
与 _read_throttled_usec 配对使用:前后差值 >=1 说明该段内发生过 GC,
可解释纯同步代码的意外停顿(高对象分配率下 gen2 GC 可达数百毫秒)。
"""
try:
import gc
return sum(s.get("collections", 0) for s in gc.get_stats())
except Exception:
return 0
def _ensure_internal_loop(method):
"""
Decorator for async_xxx() methods on HyMemoryClient.
Ensures the coroutine runs on _LoopThread's event loop, not the caller's.
If already on the correct loop, executes directly.
If on an external loop (e.g., uvicorn), delegates to _LoopThread via run_coroutine_threadsafe.
"""
import functools
@functools.wraps(method)
async def wrapper(self, *args, **kwargs):
submitted_at = time.perf_counter()
async def _invoke():
started_at = time.perf_counter()
result = await method(self, *args, **kwargs)
if isinstance(result, dict) and method.__name__ in {
"async_add", "async_add_extracted", "async_finalize_session",
}:
runtime_diag = result.setdefault("runtime", {})
runtime_diag["submit_wait_ms"] = round(
(started_at - submitted_at) * 1000, 3,
)
lag_snapshot = getattr(self._loop_thread, "lag_snapshot", None)
if callable(lag_snapshot):
runtime_diag["event_loop"] = lag_snapshot()
return result
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
internal_loop = self._loop_thread._loop
if current_loop is internal_loop:
# Already on _LoopThread's loop — execute directly
return await _invoke()
else:
# On external loop (uvicorn, etc.) — delegate to _LoopThread
future = asyncio.run_coroutine_threadsafe(
_invoke(), internal_loop
)
return await await_concurrent_future_guarded(future, internal_loop)
return wrapper
def _ensure_search_loop(method):
"""
Decorator for async_search() on HyMemoryClient.
与 _ensure_internal_loop 同构,但把协程绑定到 **search 专用 loop**
(self._search_loop_thread) 而非 write loop,使 search 的调度不被 write
队列(EXTRACT/RECONCILE/write_queue 并发)挤占。
search 与 write 使用独立 loop,避免写队列占用 search 调度。
solo 模式下 _search_loop_thread 可能与 _loop_thread 是同一个(不隔离),
行为等价于旧路径;shared 模式(SharedRuntime)下二者是两个独立线程。
"""
import functools
@functools.wraps(method)
async def wrapper(self, *args, **kwargs):
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
search_loop = self._search_loop_thread._loop
if current_loop is search_loop:
# 已经在 search loop 上 — 直接执行
return await method(self, *args, **kwargs)
else:
# 外部 loop(uvicorn 等)— 委托到 search loop
future = asyncio.run_coroutine_threadsafe(
method(self, *args, **kwargs), search_loop
)
return await await_concurrent_future_guarded(future, search_loop)
return wrapper
class _LoopThread:
"""
后台线程持有一个持久的 event loop。
解决问题:本地 Qdrant(AsyncQdrantLocal)通过文件锁绑定到创建它的 event loop,
如果每次 asyncio.run() 创建新 loop,文件锁会冲突。
用同一个 loop 运行所有协程即可。
"""
def __init__(self):
import threading
self._loop = asyncio.new_event_loop()
self._ready = threading.Event()
self._lag_lock = threading.Lock()
self._lag_handle = None
self._wakeup_guard_handle = None
self._lag_current_ms = 0.0
self._lag_max_ms = 0.0
self._lag_samples = 0
self._lag_over_100ms = 0
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
if not self._ready.wait(timeout=5):
raise RuntimeError("background event loop failed to start")
def _run(self):
asyncio.set_event_loop(self._loop)
if os.getenv("MEMORY_EVENT_LOOP_LAG_MONITOR_ENABLED", "false").lower() in (
"1", "true", "yes", "on",
):
self._schedule_lag_probe()
# Some runtimes can lose the self-pipe wakeup used by
# call_soon_threadsafe/run_coroutine_threadsafe. Keep an internal timer
# pending so the selector wakes at least every 50ms and processes any
# cross-thread callbacks already queued. This timer is scheduled by the
# loop itself and therefore does not depend on the broken wakeup path.
self._schedule_wakeup_guard()
self._loop.call_soon(self._ready.set)
self._loop.run_forever()
def _schedule_wakeup_guard(self):
def _wake():
self._schedule_wakeup_guard()
self._wakeup_guard_handle = self._loop.call_later(0.05, _wake)
def _schedule_lag_probe(self):
try:
interval = max(
0.05,
float(os.getenv("MEMORY_EVENT_LOOP_LAG_INTERVAL_SEC", "0.5")),
)
except (TypeError, ValueError):
interval = 0.5
expected = self._loop.time() + interval
def _probe():
now = self._loop.time()
lag_ms = max(0.0, (now - expected) * 1000)
with self._lag_lock:
self._lag_current_ms = lag_ms
self._lag_max_ms = max(self._lag_max_ms, lag_ms)
self._lag_samples += 1
if lag_ms >= 100.0:
self._lag_over_100ms += 1
self._schedule_lag_probe()
self._lag_handle = self._loop.call_at(expected, _probe)
def lag_snapshot(self) -> Dict[str, Any]:
with self._lag_lock:
return {
"current_ms": round(self._lag_current_ms, 3),
"max_ms": round(self._lag_max_ms, 3),
"samples": self._lag_samples,
"over_100ms": self._lag_over_100ms,
}
def run(self, coro, *, include_runtime_diag: bool = False):
"""在后台 loop 中运行协程,同步等待结果"""
if not include_runtime_diag:
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return concurrent_result_guarded(future, self._loop)
submitted_at = time.perf_counter()
async def _timed():
started_at = time.perf_counter()
result = await coro
if isinstance(result, dict):
runtime_diag = result.setdefault("runtime", {})
runtime_diag["submit_wait_ms"] = round(
(started_at - submitted_at) * 1000, 3,
)
runtime_diag["event_loop"] = self.lag_snapshot()
return result
future = asyncio.run_coroutine_threadsafe(_timed(), self._loop)
return concurrent_result_guarded(future, self._loop)
async def run_async(self, coro):
"""
在后台 loop 中运行协程,异步等待结果。
可从任意 event loop 中安全 await — 如果已经在正确的 loop 上则直接执行,
否则委托到 _LoopThread 的 loop 并 await。
"""
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
if current_loop is self._loop:
# 已经在 _LoopThread 的 loop 里,直接执行
return await coro
else:
# 在外部 loop 里(如 uvicorn),委托到 _LoopThread
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return await await_concurrent_future_guarded(future, self._loop)
def stop(self):
if self._loop.is_closed():
return
if threading.current_thread() is self._thread:
# Called from inside the loop: stopping and joining ourselves would
# deadlock. Just ask run_forever() to return.
self._loop.stop()
return
def _stop():
if self._lag_handle is not None:
self._lag_handle.cancel()
if self._wakeup_guard_handle is not None:
self._wakeup_guard_handle.cancel()
self._loop.stop()
self._loop.call_soon_threadsafe(_stop)
self._thread.join(timeout=5)
if not self._thread.is_alive() and not self._loop.is_closed():
self._loop.close()
class HyMemoryClient:
"""
HY Memory 无状态客户端。
构造函数只接受连接配置和处理模式,不绑定任何业务参数。
所有业务隔离参数(user_id/agent_id/session_id 等)在每次调用时传入,
一个 client 实例可被多线程安全共享。
处理模式(mode):
- "lite": raw-only 纯 embedding 写入/检索,不调用 LLM;忽略 scene/layers 路由
- "pro": embedding + LLM agent 提取(事实/实体/意图),标准模式
- "ultra": pro + 可显式触发的 System 2 认知加工(Schema 归纳),完整模式
数据隔离模型(三级):
- user_id: 一级 key — 每个用户唯一的记忆库
- agent_id: 二级 key — 同一用户下不同 Agent 场景的隔离
- session_id: 三级 key — 同一 Agent 下不同会话的隔离
Usage:
# lite: 纯向量写入,不调 LLM
client = HyMemoryClient(mode="lite")
# pro: 标准模式(embedding + agent 提取)
client = HyMemoryClient(mode="pro")
# ultra: 完整模式(pro + System 2 认知层)
client = HyMemoryClient(mode="ultra")
client.add("用户喜欢科幻电影", user_id="user_001")
results = client.search("用户喜欢什么?", user_ids=["user_001"])
client.close()
Args:
config: 自定义 MemoryConfig(默认从环境变量加载)
mode: 处理模式 ("lite" / "pro" / "ultra"),默认从环境变量 MEMORY_MODE 读取
embedding_service: 可选的调用方自管 embedding service;须满足公开的
EmbeddingService Protocol,client 不接管其生命周期。
"""
# 合法的 mode 值
VALID_MODES = ("lite", "pro", "ultra")
def __init__(
self,
config: Optional[MemoryConfig] = None,
mode: Optional[str] = None,
coding_writer: Optional[str] = None,
runtime: Optional["SharedRuntime"] = None,
embedding_service: Optional[EmbeddingService] = None,
):
# 确保日志系统已配置(幂等,多次调用安全)
setup_logging()
# 配置
self._config = config or MemoryConfig.from_env()
self._borrowed_embedding_service = embedding_service
self._validate_embedding_service(embedding_service)
# coding_writer 显式传入则覆盖 config / env(默认 None → 沿用 config.coding.writer)
# 取值: "legacy" | "agent"
if coding_writer is not None:
cw = str(coding_writer).strip().lower()
if cw in ("legacy", "agent"):
self._config.coding.writer = cw
else:
logger.warning(
f"[init] invalid coding_writer={coding_writer!r}; falling back to config default"
)
# 解析 mode
import os
self._mode = mode or os.getenv("MEMORY_MODE", "pro")
if self._mode not in self.VALID_MODES:
raise ValueError(
f"Invalid mode '{self._mode}'. Must be one of: {self.VALID_MODES}"
)
# 图数据库开关:与 mode 解耦。
# ultra → 恒开(System 2 写 L6_SCHEMA 依赖它)
# pro → 由 MEMORY_ENABLE_GRAPH 决定(默认关),用于记忆关系图
# lite → 恒关(lite 不做任何 LLM 加工,图无从建立)
# GraphStore 的初始化本来就在 client 启动时完成、与 evolve()/System 2 无关,
# 所以 pro 开图不需要把 System 2 拖进来。
if self._mode == "ultra":
self.enable_graph = True
elif self._mode == "pro":
from .config import _get_env_bool
self.enable_graph = _get_env_bool("MEMORY_ENABLE_GRAPH", False)
else:
self.enable_graph = False
self._config.enable_graph = self.enable_graph
# 进程级初始化日志强制不带业务 request_id(哨兵 "-"),避免污染 App
# 请求上下文里按 request_id 的过滤。
with request_id_scope():
logger.info(
f"Initializing HyMemoryClient: mode={self._mode} "
f"enable_graph={self.enable_graph}"
)
# SharedRuntime 接入(多 client 部署用,避免 cross-loop bug)
# - runtime is None → solo 模式:自己起 _LoopThread + 自己的 cache pool
# - runtime is given → shared 模式:复用 runtime 的 loop / cache / metrics 绑定
self._runtime = runtime
self._owns_runtime = runtime is None
# 创建/复用持久 event loop
if self._owns_runtime:
self._loop_thread = _LoopThread()
# solo 模式:search 也开一个独立 loop,隔离效果与 shared 模式一致。
self._search_loop_thread = _LoopThread()
else:
self._loop_thread = runtime.loop_thread
# shared 模式:用 runtime 的 search 专用 loop(与 write loop 隔离)。
# 兼容旧 runtime(无 search_loop_thread)时回落到 write loop。
self._search_loop_thread = (
getattr(runtime, "search_loop_thread", None) or runtime.loop_thread
)
# 同步初始化所有组件
self._loop_thread.run(self._initialize())
logger.info("HyMemoryClient ready")
async def _initialize(self):
"""初始化所有核心组件"""
# 进程级初始化日志不应继承外层(如 App 请求上下文)的业务 request_id,
# 否则按 request_id 过滤会混入一次性 init 噪音。强制设为哨兵 "-"。
with request_id_scope():
await self._initialize_impl()
async def _initialize_impl(self):
"""初始化所有核心组件(实际实现)"""
# 0. 扩大全局默认线程池 + 各组件后续用独立池
import concurrent.futures
loop = asyncio.get_event_loop()
_max_workers = int(os.getenv("MEMORY_THREAD_POOL_SIZE", "256"))
loop.set_default_executor(
concurrent.futures.ThreadPoolExecutor(max_workers=_max_workers)
)
logger.info(f"[init] Default ThreadPoolExecutor max_workers={_max_workers}")
# 1. EmbedService(注入发生在 ComponentFactory / pipeline 预热之前)
self._embed_service = (
self._borrowed_embedding_service
if self._borrowed_embedding_service is not None
else EmbedService(self._config)
)
logger.debug(
f"[init] EmbedService created: model={self._config.embedder.model}"
)
# 2. VectorStore(根据配置选择后端)
self._vector_store = create_vector_store(self._config)
await self._vector_store.initialize()
logger.info(
f"[init] VectorStore ready: provider={self._config.vector_store.provider} "
f"collection={self._vector_store._collection_name}"
)
# 3. GraphStore(可选,由 enable_graph 配置控制)
self._graph_store = None
if self.enable_graph:
from .data.graph_store import create_graph_store
self._graph_store = create_graph_store(self._config)
await self._graph_store.initialize()
# 检查图数据库是否真正可用(Kuzu 会静默降级为 no-op)
if hasattr(self._graph_store, '_available') and not self._graph_store._available:
raise RuntimeError(
f"GraphStore ({self._config.graph_store.provider}) init failed. "
f"Use mode='pro' instead of mode='ultra' to disable Graph, or check logs for details."
)
logger.info(
f"[init] GraphStore ready: provider={self._config.graph_store.provider}"
)
# 4. ComponentFactory(注入已初始化的共享组件)
self._registry = ComponentFactory(
config=self._config,
embedding_service=self._embed_service,
)
self._registry._shared_vector_store = self._vector_store
self._registry._shared_vector_store_initialized = True
if self._graph_store is not None:
self._registry._shared_graph_store = self._graph_store
# 5. Cache(必须在组件预热前初始化,writer 创建时需要注入 cache)
# - solo 模式:自己 create + initialize
# - shared 模式:复用 runtime 的 cache(已 init + 已装 observability hook)
if self._owns_runtime:
self._cache = create_cache(self._config)
await self._cache.initialize()
logger.info(f"[init] Cache ready (backend={self._config.cache.backend})")
else:
self._cache = self._runtime.cache
logger.info("[init] Cache reused from SharedRuntime")
self._registry._shared_cache = self._cache
# 5.1 Pipeline 可观测性 hook
# - solo 模式:自己装一次
# - shared 模式:runtime 已经在 shared cache 上装过了,跳过避免重复包装
if self._owns_runtime:
self._install_pipeline_observability_hooks()
# 6. 预热组件(确保首次 add/search 不需要等待初始化)
await self._registry.get_writer()
await self._registry.get_reader()
logger.info(f"[init] Writer & Reader ready (mode={self._mode})")
await self._initialize_local_reranker()
if self._mode == "ultra":
try:
await self._registry.get_system2_writer()
logger.info("[init] System2Writer ready (ultra mode)")
except Exception as e:
logger.warning(f"[init] System2Writer warmup failed: {e}")
# 7. HistoryStore(可选,由 config.history.enable 控制)
self._history_store = None
if self._config.history.enable:
self._history_store = HistoryStore(self._config)
await self._history_store.initialize()
logger.info("[init] HistoryStore ready")
# 8. MetricsCollector
# - solo 模式:bind cache 到 metrics 单例 + 启 background flush
# - shared 模式:runtime 已经做过,跳过(关键!避免覆盖 _cache 触发 cross-loop)
if self._owns_runtime:
from .metrics import MetricsCollector
_metrics = MetricsCollector.get()
_metrics.bind_cache(self._cache)
await _metrics.start_background_tasks()
logger.info("[init] MetricsCollector ready")
else:
logger.info("[init] MetricsCollector already bound by SharedRuntime")
# 9. Coding memory 链路占位(lazy 初始化)
# 仅在 add(messages_with_tool) 首次命中 is_coding=True 时才真正初始化。
self._coding_writer = None # CodingWriter | None
self._coding_store = None # CodingMemoryStore | None
self._coding_llm_provider = None # LLMProvider | None
self._coding_init_lock = asyncio.Lock()
# ── 按变体缓存 ────────────────────────────────────────────────────
# 两个变体的库是**分开**的(见 CodingMemoryStore.__init__ 的 _suffix 注释),
# 所以不能只留一份 store —— 否则 add(scene="working") 之后
# search(scene="coding") 会去读 working 的库,静默读错。
# `_coding_store` / `_coding_writer` 保留为"最近一次用的那个",读取侧通过
# _store_for_variant() 取正确的那一份。
self._coding_writers: Dict[str, Any] = {} # variant → CodingWriter
self._coding_stores: Dict[str, Any] = {} # variant → CodingMemoryStore
# 版本演进链层(scene="coding" 独有)。零 LLM 纯 sqlite,见 _get_chain_store。
self._chain_store = None # HyCodeMem | None
self._chain_store_failed = False # 初始化失败过就不再重试
# 链层反查真实 diff 用的本地仓路径。None = 只用对话里的 old/new 兜底抽取;
# 给不了本地仓也能用,只是 diff 来自对话原文而非 git。
self._chain_repo_root = os.getenv("MEMORY_CODING_CHAIN_REPO_ROOT") or None
async def _initialize_local_reranker(self) -> None:
"""Load and warm the configured reranker before the client is ready."""
if not getattr(self._config.recall, "local_rerank_enabled", False):
return
from .pipelines._retrieval.local_rerank import get_local_reranker
reranker = get_local_reranker(
getattr(
self._config.recall,
"local_rerank_model",
"nreimers/mmarco-mMiniLMv2-L6-H384-v1",
),
device=getattr(self._config.recall, "local_rerank_device", "auto"),
max_length=getattr(
self._config.recall, "local_rerank_max_length", 512,
),
batch_size=getattr(
self._config.recall, "local_rerank_batch_size", 16,
),
)
try:
from .utils.async_executor import to_thread_guarded
rerank_init = await to_thread_guarded(reranker.initialize)
except Exception as exc:
raise RuntimeError(
"Local rerank is enabled but initialization failed: "
f"model={reranker.model_name!r}, error={exc}"
) from exc
logger.info(
"[init] Local reranker ready: model=%s device=%s "
"load_ms=%s warmup_ms=%s",
reranker.model_name,
reranker.device,
rerank_init.get("load_ms", 0),
rerank_init.get("warmup_ms", 0),
)
def _install_pipeline_observability_hooks(self) -> None:
"""
包装 cache.store_pipeline_log:
- Log:JSONL 文件,由 MEMORY_PIPELINE_LOG_ENABLED 控制(默认开启)
- Trace:SQLite pipeline_logs,由 MEMORY_PIPELINE_TRACE_ENABLED 控制(Inspector)
- 落盘 fire-and-forget,不阻塞 search/write 热路径
"""
from .utils.pipeline_observability import install_pipeline_observability
install_pipeline_observability(self._cache, log_label="init")
@classmethod
def from_config(
cls,
config_dict: Dict[str, Any],
mode: Optional[str] = None,
embedding_service: Optional[EmbeddingService] = None,
) -> "HyMemoryClient":
"""
从字典创建客户端(类似 mem0 风格)。
Usage:
client = HyMemoryClient.from_config({
"vector_store": {
"provider": "qdrant",
"collection_name": "my_memories",
},
"graph_store": {
"provider": "neo4j",
"url": "bolt://localhost:7687",
"username": "neo4j",
"password": "password",
},
"enable_graph": True,
"llm": {
"provider": "openai",
"model": "deepseek-chat",
"api_key": "sk-xxx",
},
"embedder": {
"provider": "openai",
"model": "text-embedding-v3",
"api_key": "sk-xxx",
},
})
"""
config = MemoryConfig.from_dict(config_dict)
resolved_mode = mode or config_dict.get("mode")
return cls(
config=config,
mode=resolved_mode,
embedding_service=embedding_service,
)
def _validate_embedding_service(
self, embedding_service: Optional[EmbeddingService]
) -> None:
"""Validate a borrowed service before any store or pipeline is created."""
if embedding_service is None:
return
required = (
"embed",
"embed_with_meta",
"embed_batch",
"embed_queued",
"embed_queued_with_meta",
"get_embedding_dims",
)
missing = [
name for name in required
if not callable(getattr(embedding_service, name, None))
]
if missing:
raise TypeError(
"embedding_service does not implement EmbeddingService; "
f"missing: {', '.join(missing)}"
)
try:
actual_dims = int(embedding_service.get_embedding_dims())
except Exception as exc:
raise TypeError(
"embedding_service.get_embedding_dims() must return an integer"
) from exc
expected_dims = getattr(self._config.vector_store, "embedding_dims", None)
if expected_dims is None:
expected_dims = getattr(self._config.embedder, "embedding_dims", None)
if expected_dims is not None and actual_dims != int(expected_dims):
raise EmbeddingDimensionMismatchError(
"embedding service dimension mismatch: "
f"service={actual_dims}, vector_store={int(expected_dims)}"
)
# ================================================================
# 属性
# ================================================================
@property
def mode(self) -> str:
"""当前处理模式 (lite/pro/ultra)"""
return self._mode
# ================================================================
# 内部
# ================================================================
@staticmethod
def _build_isolation_key(uid: str, agent_id: str) -> str:
"""
构建 history 用的 isolation_key。
两级隔离: "{user_id}:{agent_id}"
"""
parts = [uid or "default", agent_id or "default"]
return ":".join(parts)
@staticmethod
def _normalize_scene(scene: Optional[str]) -> Optional[str]:
"""
归一化用户显式指定的场景参数 —— 只解析**路由**,不区分变体。
只接受三个值(大小写不敏感):
- "chat" → 走 chat 链路
- "working" → 走 coding 链路(通用生产力变体)
- "coding" → 走 coding 链路(软件工程变体,见 _scene_variant)
- None → 未指定,由 LLM 自动判定
"working" 和 "coding" 在这里归一成同一个返回值 "coding":它们走的是同一种
生产力 pipeline,但使用按变体隔离的 store / writer / reader。抽取规范及是否挂
版本链层由 _scene_variant() 单独解析。这样 add()/search() 里所有
`scene_forced == "coding"` 的判断对两者同时生效,不必逐处加分支。
Returns:
"coding" | "chat" | None
非法值抛 ValueError。
"""
if scene is None:
return None
s = str(scene).strip().lower()
if s == "chat":
return "chat"
if s in ("working", "coding"):
return "coding"
raise ValueError(
f"scene must be 'chat', 'working' or 'coding', got {scene!r}"
)
@staticmethod
def _scene_variant(scene: Optional[str]) -> str:
"""
解析 coding 链路的**变体**。与 _normalize_scene 正交:那个决定走哪条链,
这个决定走上去之后用哪套规范。
- "coding" → "coding" 软件工程场景
- 其他 / None → "working" 通用生产力场景(原有行为)
这个 helper 对 None 返回 working 只是兼容 fallback;真正的 scene=None 自动
路由会由三分类 judge 回填 working/coding,不会固定吞成 working。
两个变体共用表结构和处理流程,但使用独立 SQLite 文件与向量集合。行为差异为:
1. 抽取规范 —— coding 用 EXTRACT_PROMPT_CODING(HARD CONSTRAINT 头部 +
CODE WORK 节:分支名逐字、哪一版赢了、精确坐标、verified vs written)。
2. 版本演进链层 —— coding 额外挂 coding.chain_store:零 LLM 地存下每处
代码每一版的真实 patch。治的是抽取规范本身砍掉的那类信息(它明文要求
"EXACT COORDINATES, NOT PASTED CODE",而被撤掉那版的代码不在工作区、
不在 HEAD,只在某次中间提交里,读不回来)。
"""
return "coding" if str(scene or "").strip().lower() == "coding" else "working"
@staticmethod
def _parse_input(data: AddInput):
"""
解析 add() 的输入,统一返回 (content, chat_messages, content_type)。
str → ("文本", [], "text")
list → ("", [ChatMessage, ...], "messages")
list 模式下兼容两种 tool 调用风格,规范化到统一的 ChatMessage:
- OpenAI: assistant.tool_calls (function.arguments 是 JSON 字符串) +
独立 role="tool" 消息(含 tool_call_id / name)
- Anthropic: assistant.content 为 list[content_block],含 type=tool_use;
tool_result 嵌在后续 role="user" 消息的 content list 里,
需要拆为独立的 role="tool" 规范化消息
一条原始消息可能产出 0~多 条 ChatMessage(Anthropic tool_result 拆分)。
"""
if isinstance(data, str):
return data, [], "text"
if isinstance(data, list):
chat_messages: List[ChatMessage] = []
for raw in data:
chat_messages.extend(HyMemoryClient._normalize_message(raw))
return "", chat_messages, "messages"
raise TypeError(
f"add() 第一个参数应为 str 或 list[dict],收到 {type(data).__name__}"
)
@staticmethod
def _normalize_message(m: Dict[str, Any]) -> List[ChatMessage]:
"""
把单条原始消息规范化到 ChatMessage 列表。
返回值通常是单条;当原始消息是 Anthropic 风格 user 消息且其 content
list 含 tool_result block 时,会拆为 [user_text_msg?, tool_msg, ...]。
"""
role = m.get("role", "user")
raw_content = m.get("content")
raw_tool_calls = m.get("tool_calls", []) or []
# 部分客户端(如 codebuddy)会把 tool_calls 编码成 JSON 字符串而非 list,
# 这里统一规范化为 list,避免下面的 for 循环把字符串当成可迭代对象。
if isinstance(raw_tool_calls, str):
try:
parsed_tool_calls = json.loads(raw_tool_calls) if raw_tool_calls else []
except Exception:
logger.warning(
"tool_calls 是非法 JSON 字符串,已忽略 (%d chars)", len(raw_tool_calls)
)
parsed_tool_calls = []
raw_tool_calls = parsed_tool_calls if isinstance(parsed_tool_calls, list) else []
elif not isinstance(raw_tool_calls, list):
raw_tool_calls = []
# ── 1. OpenAI assistant.tool_calls ──
tool_calls: List[ToolCall] = []
for tc in raw_tool_calls:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {}) or {}
args = fn.get("arguments")
if isinstance(args, str):
# OpenAI 编码为 JSON 字符串
try:
args = json.loads(args) if args else {}
except Exception:
args = {"_raw": args}
elif args is None:
args = {}
elif not isinstance(args, dict):
args = {"_raw": args}
tool_calls.append(ToolCall(
id=tc.get("id", "") or "",
name=fn.get("name", "") or "",
arguments=args,
))
# ── 2. OpenAI 独立 tool 消息 ──
# 注意:tool 消息的 `name` 是**工具名**,不是说话人名,仍归 tool_name。
if role == "tool":
return [ChatMessage(
role="tool",
content=str(raw_content or ""),
tool_call_id=m.get("tool_call_id"),
tool_name=m.get("name"),
)]
# 非 tool 角色的 `name` 是**说话人显示名**(OpenAI messages 规范)。
# 多真人对话靠它区分对方是谁:role=assistant + name → 对方真人而非 AI。
speaker_name = m.get("name") or None
# ── 3. Anthropic content blocks ──
if isinstance(raw_content, list):
text_parts: List[str] = []
tool_result_msgs: List[ChatMessage] = []
for block in raw_content:
if not isinstance(block, dict):
# 非标准形态:当字符串处理
text_parts.append(str(block))
continue
btype = block.get("type")
if btype == "text":
text_parts.append(block.get("text", "") or "")
elif btype == "tool_use":
# Anthropic:input 已是 dict
tool_input = block.get("input", {}) or {}
if not isinstance(tool_input, dict):
tool_input = {"_raw": tool_input}
tool_calls.append(ToolCall(
id=block.get("id", "") or "",
name=block.get("name", "") or "",
arguments=tool_input,
))
elif btype == "tool_result":
# 拆为独立 role=tool 消息
tc_id = block.get("tool_use_id")
inner = block.get("content", "")
if isinstance(inner, list):
# 嵌套 content list(通常是 [{type:text,text:...}])
inner = "\n".join(
b.get("text", "") if isinstance(b, dict) and b.get("type") == "text"
else str(b)
for b in inner
)
elif not isinstance(inner, str):
inner = str(inner)
tool_result_msgs.append(ChatMessage(
role="tool",
content=inner,
tool_call_id=tc_id,
tool_name=None, # Anthropic tool_result 没带 name
))
# 其他未知 block 类型静默忽略
text = "\n".join(p for p in text_parts if p).strip()
outputs: List[ChatMessage] = []
# 即使 text 为空,只要 tool_calls 非空也保留 assistant 消息
if text or tool_calls:
outputs.append(ChatMessage(
role=role,
content=text,
name=speaker_name,
tool_calls=tool_calls,
))
outputs.extend(tool_result_msgs)
return outputs
# ── 4. 普通 string content ──
# role=="tool" 已在分支 2 提前返回,这里不会是 tool 消息,故不再填 tool_name
# (旧代码把 `name` 塞进 tool_name,对非 tool 角色是无意义的)。
content_str = "" if raw_content is None else str(raw_content)
return [ChatMessage(
role=role,
content=content_str,
name=speaker_name,
tool_calls=tool_calls,
tool_call_id=m.get("tool_call_id"),
)]
# ================================================================
# Coding memory lazy init
# ================================================================
async def _get_coding_writer(self, variant: str = "working"):
"""
Lazy 初始化 coding 写入链路。**按变体各一份**。
命中 is_coding=True 时才真正构造 store / extractor / reconciler。
chat 路径的用户永远不会触发这块开销。
legacy CodingWriter = extractor + reconciler。
variant("working" | "coding") 决定两件事:
1. 用哪份抽取规范(见 _scene_variant)
2. 读写哪个库 —— 两个变体的 sqlite 和向量集合都是分开的
两个变体各缓存一份 writer/store,可以在同一个 client 上混用:
add(scene="working") 和 add(scene="coding") 各写各的库,
search 时按 scene 取对应那一份,不会串。
`self._coding_store` / `self._coding_writer` 同步指向**最近一次**用的那份,
只为兼容既有代码路径(如 list_memories)。要按变体取请走
_store_for_variant()。
"""
cached = self._coding_writers.get(variant)
if cached is not None:
self._coding_writer = cached
self._coding_store = self._coding_stores.get(variant)
return cached
async with self._coding_init_lock:
cached = self._coding_writers.get(variant)
if cached is not None:
self._coding_writer = cached
self._coding_store = self._coding_stores.get(variant)
return cached
# 局部 import 避免冷启动开销
from .agent.llm_provider import LLMProvider
from .coding.store import CodingMemoryStore
# 共享 LLMProvider(与 chat 链路相同 LLMConfig.model)
if self._coding_llm_provider is None:
self._coding_llm_provider = LLMProvider(self._config)
# Store —— 按变体分库(db_path 和两个向量集合都带后缀),见
# CodingMemoryStore.__init__ 里 _suffix 的注释。variant="working" 时
# 后缀为空,路径与集合名和改动前逐字相同。
store = CodingMemoryStore(
self._config,
embed_service=self._embed_service,
db_path=self._config.coding.db_path,
variant=variant,
)
await store.initialize()
# extractor + reconciler 串联
from .coding.extractor import CodingMemoryExtractor
from .coding.reconciler import CodingMemoryReconciler
from .coding.writer import CodingWriter
extractor = CodingMemoryExtractor(
self._coding_llm_provider,
cache=self._cache,
scene=variant,
verbatim_fallback_enabled=bool(
getattr(
self._config.coding,
"verbatim_fallback_enabled",
False,
)
),
)
reconciler = CodingMemoryReconciler(
store=store,
embed_service=self._embed_service,
llm_provider=self._coding_llm_provider,
cache=self._cache,
)
writer = CodingWriter(
store=store,
extractor=extractor,
reconciler=reconciler,
llm_provider=self._coding_llm_provider,
embed_service=self._embed_service,
cache=self._cache,
variant=variant,
)
self._coding_stores[variant] = store
self._coding_writers[variant] = writer
# 兼容既有路径:指向最近一次用的那份
self._coding_store = store
self._coding_writer = writer
logger.info(
"[coding] writer chain initialized (lazy), variant=%s", variant
)
return writer
def _store_for_variant(self, variant: str = "working"):
"""取某个变体的 store。没初始化过返回 None。
读取侧必须走这个而不是直接读 self._coding_store —— 后者是"最近一次用的
那份",在 add(working) 之后 search(coding) 会拿到 working 的库。
"""
return self._coding_stores.get(variant) or None
@staticmethod
def _chain_owner(user_id=None, workspace_id=None) -> str:
"""链层的隔离键。
`<user_id>|<workspace_id>` —— 两个都空时返回 ""。空串只匹配空串,
不是公共池,所以旧数据不会被新写入的行串进来。
为什么 user_id 必须在键里:thread 的键原来只有 (branch, file, pkey),
不带 owner 时不同用户的同名 branch/file/pkey 会并成一条链,造成数据泄漏。
"""
u = str(user_id or "").strip()
w = str(workspace_id or "").strip()
return "" if not (u or w) else "%s|%s" % (u, w)
def _get_chain_store(self, owner: str = ""):
"""
Lazy 初始化版本演进链层(scene="coding" 独有)。
零 LLM、零 embedding、纯 sqlite —— 构造代价可以忽略,所以不用异步锁。
失败返回 None:这层是**增量**,拿不到就退回纯向量双层(= working 的行为),
不该让一次 add/search 整体失败。
owner 变了要换实例(同一个 client 服务多个 user 时)—— 实例只是个 sqlite
连接 + owner 字符串,重建很便宜,而复用会让 owner 过滤失效。
库文件是同一个,所以不同 owner 共享文件、靠 WHERE 隔离。
库位置:跟 coding_memory.db 同目录下的 hy_code_chain/hy_code.db。不并进同一个
文件是因为 CodingMemoryStore 自己管 schema 迁移,塞三张表进去会让两边的迁移
逻辑互相踩;两个文件同目录,备份/清理时不会漏。
"""
if self._chain_store is not None and getattr(
self._chain_store, "owner", "") == owner:
return self._chain_store
if self._chain_store_failed:
return None
try:
import os as _os
from .coding.chain_store import HyCodeMem
_dir = _os.path.join(
_os.path.dirname(_os.path.abspath(self._config.coding.db_path)),
"hy_code_chain",
)
self._chain_store = HyCodeMem(
_dir, repo_root=self._chain_repo_root, owner=owner)
logger.info("[coding] chain store initialized at %s (owner=%r)",
_dir, owner)
return self._chain_store
except Exception as e:
# 只试一次。失败通常是环境性的(目录不可写 / sqlite 不可用),重试每次
# add 都付一遍代价却不会变好。
self._chain_store_failed = True
logger.warning(
"[coding] chain store init failed, falling back to vector-only: %s", e
)
return None
# ----------------------------------------------------------------
# 版本演进链层的两个胶水方法(写 / 读)
# ----------------------------------------------------------------
@staticmethod
def _chain_text(chat_messages) -> str:
"""把一次 add 的 messages 渲染成链层能读的纯文本。
形态刻意对齐 chain_store 里 _USER_LC / _DIFF_END 认的小写前缀
(`user:` / `assistant:` / `[tool:name]`)。大写 `USER:` 也能被 _USER 识别,
但工具消息无法进入同一套边界判定。
为什么不复用 CodingMemoryExtractor._format_conversation:那个给每行加了
`[i] ` 下标前缀(给 LLM 读的),链层的正则锚在行首,前缀一加全都匹配不上。
"""
rows = []
for m in (chat_messages or []):
try:
content = m.content if getattr(m, "content", None) else ""
if getattr(m, "is_tool_message", None) and m.is_tool_message():
rows.append(f"[tool:{getattr(m, 'tool_name', None) or '?'}] {content}")
continue
role = getattr(m, "role", "") or "user"
rows.append(f"{role}: {content}")
except Exception:
continue
return "\n".join(rows)
def _observe_chain(self, chat_messages, *, user_id=None, workspace_id=None,
branch=None) -> int:
"""写入侧:把这一段对话里的代码改动存进版本演进链。
返回存下的 hunk 数(0 = 这段没有可识别的代码改动,正常情况,不是错误)。
全程零 LLM、零 embedding、纯 sqlite + 正则,适合在写链路同步执行。
best-effort:任何异常只记 warning。这层是增量,它失败时向量双层已经写完了,
把整个 add 拖失败是净亏。
"""
# 走类而不是 self:_chain_owner 是 staticmethod,而这两个方法会被测试绑到裸
# SimpleNamespace 上调(同 _scene_variant 那处),self 上没有这个属性。
store = self._get_chain_store(
HyMemoryClient._chain_owner(user_id, workspace_id))
if store is None:
return 0
try:
text = self._chain_text(chat_messages)
if not text.strip():
return 0
# branch 给了就不猜(meta 里的 branch 优先于从文本里正则认)。
# workspace_id 不单独透传给 observe:它已经进了 owner(见 _chain_owner),
# thread 的键里不再需要第二个仓维度。
return int(store.observe(text, meta={"branch": branch or ""}) or 0)
except Exception as e:
logger.warning("[coding] chain observe failed: %s", e)
return 0
def _chain_block_enabled(self) -> bool:
"""search 是否注入 coding 版本演进链块(diff / chain_block)。
公共开关:CodingConfig.chain_block_enabled /
MEMORY_CODING_CHAIN_BLOCK_ENABLED(默认 False,opt-in)。
无 _config 时按关闭处理。高级消融开关 HCM_BLOCK_OFF=1 仍在
chain_store.decorate 内生效(仅当本开关已开启时才有意义)。
"""
coding = getattr(getattr(self, "_config", None), "coding", None)
if coding is None:
return False
return bool(getattr(coding, "chain_block_enabled", False))
def _decorate_with_chain(self, facts, instruction: str = "", scope=None,
user_id=None, workspace_id=None):
"""读取侧:给向量层召回的 facts 挂上版本演进链块。
返回 (block, used_idx):
block —— 要放在 fact 列表**之前**的多行结构块(挂不上任何链时为 "")
used_idx —— 已挂进 block 的 fact 下标;调用方应把这些从 fact 列表里去掉,
避免同一条 fact 出现两次。
facts 顺序不重排 —— top-k 是向量层的事,这层只负责"每条 fact 后面跟什么"。
best-effort:失败返回 ("", []),退化成纯向量双层(= working 的行为)。
默认不注入(见 _chain_block_enabled);需显式开启 chain_block_enabled。
"""
if not facts:
return "", []
# 走类而不是 self:测试常把本方法绑到裸 SimpleNamespace 上,self 上未必有
# 同名 bound method;_chain_owner 同理。
if not HyMemoryClient._chain_block_enabled(self):
return "", []
store = self._get_chain_store(
HyMemoryClient._chain_owner(user_id, workspace_id))
if store is None:
return "", []
try:
return store.decorate(facts, instruction=instruction, scope=scope)
except Exception as e:
logger.warning("[coding] chain decorate failed: %s", e)
return "", []
async def _search_coding(
self,
target_query: str,
*,
user_ids: List[str],
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
workspace_id: Optional[str],
branch: Optional[str],
limit: int,
working_fact_min_score: Optional[float] = None,
working_experience_min_score: Optional[float] = None,
request_id: str,
t0: float,
variant: str = "working",
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
rerank: bool = True,
query_embedding: Optional[List[float]] = None,
) -> Dict[str, Any]:
"""
Coding 召回路径(事实主导的双层召回):
1. 拿 store(lazy init),ensure 已 initialize
2. embed(target_query) —— 一次,两层共用同一个向量
3. 事实主索引:召回 top-K 的 active facts
4. 顺链带出经验:沿命中事实的 derived_experience_ids 拉经验
5. 经验直查兜底:同一向量直接搜经验层,合并进来
6. 去重(多事实指向同一经验只留一条)+ 存在性过滤(死链自然滤掉)
7. 标注 via: fact_link / direct 后返回
limit 是**每层的返回上限**:facts ≤ limit,experiences ≤ limit,所以一次调用
最多给出 2*limit 条。两层分开计数而不是共用一个总预算——它们承担的角色不同,
事实答"是什么"、经验答"怎么做",谁挤掉谁都不合理。
取数时都会多捞一些再落回 limit:事实层按 max(limit, 5) 取、经验层顺链和直查
各取至多 limit 条,中间要过阈值、去重、剔死链,不多捞就填不满。
两道闸门,每层一道,谁也不豁免:
working_fact_min_score 事实层,默认 0.30
working_experience_min_score 经验层,默认事实阈值 + 0.10
经验那道对顺链和直查一视同仁。两路卡的本来就是同一批分数(都来自下面那次
直查,顺链只是借用),同一个尺度上再分两档没有依据。事实背书体现在**排序**
上——顺链的排在直查前面,limit 截断时先保住——而不是准入上打折。
workspace_id / branch 是**作用域筛选条件**,两层都生效:不传就不筛(全量
召回),传了则只在作用域内召回。规则由 coding.store._scope_where 定义,与
list_memories 共用一套:workspace_id 精确匹配;branch 只在 workspace_id
非空时叠加,且容 NULL(没记录分支的行不算"记录了别的分支")。过滤下推到
SQLite(这两列的权威来源),向量层按作用域选择率放大取数,所以过滤不吃召回。
作用域把事实层筛空时,顺链那一路跟着断源,本次检索退化成只有经验直查的单
层。这是可接受的——事实是"这个 repo 里发生过什么",跨 workspace 拿它去带
经验没有意义——但结果里看不出来,所以退化时打 warning。
rerank=False 时事实层跳过 BM25 词面融合重排(不传 query_text 给
store.search_facts),按稠密分排序。
失败时 fail-safe:返回 success=True + 两层都空(不抛错)。
"""
fact_min_score = (_WORKING_FACT_DEFAULT_MIN_SCORE
if working_fact_min_score is None else working_fact_min_score)
exp_min_score = (fact_min_score + _WORKING_EXPERIENCE_MIN_SCORE_DELTA
if working_experience_min_score is None
else working_experience_min_score)
# coding 路径 trace(与 chat 路径共用 pipeline_logs;best-effort)
_tracer = None
if getattr(self, "_cache", None) is not None:
try:
from .pipelines._retrieval.trace import ReadTraceLogger
_tracer = ReadTraceLogger(
cache=self._cache,
request_id=request_id,
user_id=(user_ids[0] if user_ids else ""),
agent_id="default_agent",
reader_version="coding",
)
except Exception:
_tracer = None
def _empty() -> Dict[str, Any]:
_r = {
"request_id": request_id,
"scene": "productivity",
"facts": [],
"experiences": [],
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
}
# 键集合与成功路径保持一致 —— 否则调用方对 fail-safe 的空结果要写两套
# 取值逻辑,而 fail-safe 本来就是"不抛错,返回空"。
if variant == "coding":
_r["chain_block"] = ""
_r["chain_used"] = []
return _r
try:
# ★ 必须传 variant:两个变体的库是分开的,不传就会去读 working 的库
# (静默读错,症状是"明明灌进去了却召不回来")。
# 现在按变体各缓存一份 writer/store,所以传了也不会把另一个变体的
# extractor 定死 —— 早先那版单实例缓存才有那个问题。
#
# 用 TypeError 兜一次零参调用:测试会把这个方法换成零参 stub
# (tests/coding/ 里三处 _stub_get_coding_writer),而它们只是要
# "store 已就绪"这个副作用,不关心变体。给 stub 硬塞 kwarg 会让
# 一批与本改动无关的测试红掉,那是接口洁癖而非正确性。
try:
await self._get_coding_writer(variant=variant) # 副作用:初始化 store
except TypeError as _te:
if "variant" not in str(_te):
raise
await self._get_coding_writer()
except Exception as e:
logger.warning(f"[search-coding] init failed: {e}")
return _empty()
# 取**这个变体**的 store。不直接用 self._coding_store —— 那是"最近一次用的
# 那份",add(working) 之后 search(coding) 会拿到错的库。
#
# 两级兜底都是为测试留的,不是防御性编程:tests/coding/ 的 fixture 把这个方法
# 绑到裸 SimpleNamespace 上调(self 上既没有 _coding_stores 也没有
# _store_for_variant),而它们直接往 ns 上挂好了 _coding_store。
_store = (getattr(self, "_coding_stores", None) or {}).get(variant) \
or getattr(self, "_coding_store", None)
# 多 user_ids 时取首位(coding memory 一般跟单 user 绑定;
# 跨 user 的 coding memory 检索属未来扩展)
primary_user = user_ids[0]
try:
_t_embed = time.perf_counter()
if query_embedding:
# 调用方已算好(scene="all" 三路共用一次 embed)
embedding, embed_meta = list(query_embedding), {}
else:
embedding, embed_meta = await self._embed_service.embed_with_meta(
target_query
)
if _tracer is not None:
await _tracer.log_coding_embed(
query=target_query, dims=len(embedding or []),
elapsed_ms=(time.perf_counter() - _t_embed) * 1000,
provider_meta=embed_meta,
)
except Exception as e:
logger.warning(f"[search-coding] embed failed: {e}")
return _empty()
# ---- 1. 事实主索引 ----
try:
fact_hits = await _store.search_facts(
embedding, user_id=primary_user,
agent_ids=agent_ids, session_ids=session_ids,
workspace_id=workspace_id, branch=branch,
top=max(limit, 5),
# 词面通道 + 把下面那道闸门下移到重排之前:先砍掉过不了闸的候选再重排,
# 返回条数就恒等于纯稠密,词面只改选谁(理由见 store.search_facts)。
query_text=(target_query if rerank else None),
min_score=fact_min_score,
include_sources=include_sources,
exclude_sources=exclude_sources,
)
except Exception as e:
logger.warning(f"[search-coding] fact search failed: {e}")
fact_hits = []
# 事实层是主索引,噪声会顺 derived_experience_ids 放大成一串无关经验,
# 所以这道闸门比经验直查那路更要紧
_fact_top_score = max((s for _, s in fact_hits), default=0.0)
_fact_raw = len(fact_hits)
fact_hits = [(f, s) for f, s in fact_hits if s >= fact_min_score]
if _fact_raw and not fact_hits:
# 召回到了事实但全被阈值挡下:顺链那一路跟着断源,本次检索实际退化成
# 只有经验直查的单层。这种退化在结果里看不出来(经验照样有),只能在
# 这里报,否则阈值调过头了没人知道。
logger.warning(
f"[search-coding] fact layer emptied by threshold: "
f"{_fact_raw} hit(s), top={_fact_top_score:.3f} < "
f"working_fact_min_score={fact_min_score}; "
f"fact_link path disabled for this query"
)
elif workspace_id and not fact_hits:
# 作用域过滤把事实层清空(或这个 workspace 本来就没事实)。后果与上面
# 那种一样:顺链断源,本次检索退化成只有经验直查的单层。结果里看不出
# 来,只能在这里报。
logger.warning(
f"[search-coding] fact layer empty under scope "
f"workspace_id={workspace_id!r} branch={branch!r}; "
f"fact_link path disabled for this query"
)
# 事实层多取了几条(top 有 5 的下限,且阈值会砍掉一部分),这里落回 limit。
# 顺链要基于最终返回的事实来走,否则会出现"经验标了 via=fact_link,但担保它的
# 那条事实没在结果里"。
fact_hits = fact_hits[:limit]
out_facts: List[Dict[str, Any]] = []
linked_ids: List[str] = []
_seen_linked = set()
for fact, score in fact_hits:
out_facts.append({
"fact_id": fact.fact_id,
"statement": fact.statement,
"files": list(fact.files),
# 会话主体。覆盖闸门用它,检索侧带出来是为了让消费方能分辨
# "这几条讲的是不同的人/文件",而不是把它们读成同一件事的复述。
"subject": fact.subject,
# 被 statement 摘要掉的原文(计划表、配置块、命令输出)。摘要负责
# 被召回,这一段负责召回之后真的有那个值——点查类问题要的是格子里的
# 取值,不是"这次做了什么"的概述。不参与检索。
"details": fact.details,
# LLM 写的"这个结论一路怎么改成现在这样",直接进提示词;
# 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。
"fact_chain_summary": fact.fact_chain_summary,
"memory_at": _epoch_seconds(fact.memory_at),
"created_at": fact.created_at.isoformat() if fact.created_at else None,
"score": round(float(score), 4),
"source": fact.source,
})
# ---- 2. 顺链带出经验(按事实分数序,多事实指向同一经验只留首次) ----
for eid in fact.derived_experience_ids:
if eid not in _seen_linked:
_seen_linked.add(eid)
linked_ids.append(eid)
# 这里不截断。id 列表里可能混着死链(经验被 DELETE,事实上的引用还留着),
# 死了几条只有 get_many 查过才知道,任何固定余量都可能被吃穿。统一等取完
# 详情、死链自然掉队之后再按 limit 截一次。
# 长度天然有界:facts 已经 ≤ limit,每条的 derived_experience_ids 是个位数。
# ---- 3. 经验直查兜底 ----
try:
_t_vdb = time.perf_counter()
hits = await _store.search_by_query_embedding(
embedding,
user_id=primary_user,
agent_ids=agent_ids,
session_ids=session_ids,
workspace_id=workspace_id,
branch=branch,
top=max(limit * 4, 8), # 过采样为 dedup 留余量
include_sources=include_sources,
exclude_sources=exclude_sources,
)
except Exception as e:
logger.warning(f"[search-coding] vdb search failed: {e}")
hits = []
_t_vdb = time.perf_counter()
_raw_hits = len(hits)
_top_scores = [h.get("score", 0.0) for h in hits[:10]]
# 过滤前先把分数留一份:顺链那批经验自己没有分(它们是顺 id 拉出来的,
# 不是搜出来的),要卡阈值只能借这一轮的分。同一个 query 向量,不额外调用。
score_by_id = {h["experience_id"]: h.get("score", 0.0) for h in hits}
hits = [h for h in hits if h.get("score", 0.0) >= exp_min_score]
# 同上,不在这里截。长度由上面 VDB 的 top 兜着。
# ---- 2b. 顺链那批也要过闸门 ----
# 早期这一路是免检的(理由是"相关性由命中的事实担保"),但担保太弱:
# 一条事实可能在某个侧面匹上 query,却把它另一个侧面的经验一起拖进来。
# 现在跟直查同一道闸门:两边卡的本来就是同一批分数,分两档没有依据。
# 分数取不到的(没出现在直查的 top-N 里)视为不合格:连前 N 都进不去,
# 相似度必然低于第 N 名,正常语料下够不着闸门。
#
# 同阈值意味着顺链不再额外放行任何经验(能过闸的必然也在 direct_ids 里),
# 它的作用退成排序:顺链的排在前面,limit 截断时先保住。
linked_ids = [e for e in linked_ids
if score_by_id.get(e, 0.0) >= exp_min_score]
linked_set = set(linked_ids)
if _tracer is not None:
await _tracer.log_coding_vdb(
query=target_query, raw_hits=_raw_hits, kept_hits=len(hits),
min_score=exp_min_score, top_scores=_top_scores,
workspace_id=workspace_id, branch=branch,
elapsed_ms=(time.perf_counter() - _t_vdb) * 1000,
)
direct_ids = [h["experience_id"] for h in hits]
hit_by_id = {h["experience_id"]: h for h in hits}
# ---- 4. 去重 + 存在性过滤 ----
# 顺链在前、直查在后;get_many 查不到的(经验已被 DELETE / 两库不一致)
# 自然被丢掉,死链不用单独处理
ordered_ids = linked_ids + [e for e in direct_ids if e not in linked_set]
# 纯安全阀:get_many 走 SQLite 的 IN 子句,绑定变量个数有上限(默认 999)。
# 正常量级远够不到,这里只防病态长的链。语义上的条数控制在下面按 limit 做。
ordered_ids = ordered_ids[:_CODING_GET_MANY_MAX_IDS]
try:
details = await _store.get_many(
ordered_ids, user_id=primary_user,
include_sources=include_sources,
exclude_sources=exclude_sources,
)
except Exception as e:
logger.warning(f"[search-coding] fetch details failed: {e}")
details = []
# 唯一一道语义截断。放在 get_many 之后:死链到这一步已经掉队了,
# 先截的话它们会白占名额,实际返回数够不到 limit。
# 顺链在前,所以砍掉的优先是直查那批——有事实背书的更该留。
details = details[:limit]
# ---- 5. 标注来源 ----
out_experiences: List[Dict[str, Any]] = []
for d in details:
h = hit_by_id.get(d.experience_id) or {}
out_experiences.append({
"experience_id": d.experience_id,
"agent_id": getattr(d, "agent_id", "") or "",
"task": d.task,
"search_keys": list(d.search_keys),
"solution": d.solution,
"boundary_envs": d.boundary_envs,
"workspace_id": d.workspace_id,
"branch": d.branch,
"files": list(d.files),
"source_fact_ids": list(d.source_fact_ids),
"via": "fact_link" if d.experience_id in linked_set else "direct",
"matched_key": h.get("matched_key_text"),
"matched_key_kind": h.get("matched_key_kind"),
# 顺链那批不在 hit_by_id 里(分数够不上直查阈值才需要顺链救),
# 但 score_by_id 留了它们过滤前的分,这里透出来
"score": (round(float(score_by_id[d.experience_id]), 4)
if d.experience_id in score_by_id else None),
"confidence": d.confidence,
"memory_at": _epoch_seconds(d.memory_at),
"created_at": d.created_at.isoformat() if d.created_at else None,
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
"source": d.source,
"extraction_source": d.extraction_source,
})
elapsed = round((time.perf_counter() - t0) * 1000, 2)
logger.info(
f"[search-coding] facts={len(out_facts)} experiences={len(out_experiences)} "
f"(fact_link={sum(1 for m in out_experiences if m['via'] == 'fact_link')}) "
f"elapsed_ms={elapsed} workspace_id={workspace_id!r} branch={branch!r}"
)
if _tracer is not None:
await _tracer.log_coding_result(
query=target_query,
returned=len(out_experiences),
# memory_ids 是 pipeline_logs 的通用列名(与 chat 路径共用),
# 这里装的是 coding 侧的 experience_id
memory_ids=[m["experience_id"] for m in out_experiences],
previews=[
{"experience_id": m["experience_id"], "task": (m.get("task") or "")[:120],
"score": m.get("score"), "via": m.get("via")}
for m in out_experiences[:10]
],
elapsed_ms=elapsed,
)
resp = {
"request_id": request_id,
"scene": "productivity",
"facts": out_facts,
"experiences": out_experiences,
"elapsed_ms": elapsed,
}
# 版本演进链块(scene="coding" 独有)。放在最后:它吃的是上面已经定稿的
# out_facts,链层不参与召回和排序。
#
# 注意返回形状:块单独放在 "chain_block" 字段,**不**把挂上链的 fact 从
# out_facts 里删掉。不同调用方可能自行拼 prompt,悄悄少给 facts 比多给一个
# 字段危险得多;需要“块 + 剩余 facts”时可按 chain_used 自行过滤。
if variant == "coding":
_blk, _used = self._decorate_with_chain(
out_facts, instruction=target_query,
scope=(workspace_id or None),
user_id=primary_user, workspace_id=workspace_id,
)
resp["chain_block"] = _blk
resp["chain_used"] = _used
return resp
# ================================================================
# 同步 API
# ================================================================
def add(
self,
data: AddInput,
*,
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "default_session",
source: str = "default",
metadata: Optional[Dict[str, Any]] = None,
memory_at: Optional[datetime] = None,
scene: Optional[str] = None,
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
request_id: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
写入记忆。
处理深度由 Client 初始化时的 mode 决定:
- lite: 纯 embedding 写入
- pro: embedding + LLM agent 提取(事实/实体/reconcile)
- ultra: pro + 可通过 evolve() 显式触发的 System 2 认知加工
Args:
data: 文本字符串,或 messages 列表
- "用户喜欢科幻电影"
- [{"role": "user", "content": "..."}, ...]
user_id: 用户 ID(一级隔离 key)
agent_id: Agent ID(二级隔离 key)
session_id: Session ID(三级隔离 key)
source: 调用来源,例如 openclaw / cursor / codex;与 agent_id 正交。
写入前执行 strip().lower(),默认 "default"。
metadata: 旧版 SDK 处理选项,会并入内部 WriteRequest.extra。
不保证持久化或随查询返回;调用方需要保存并读回的业务字段
请使用 meta。
memory_at: 记忆时间戳(不传则用当前时间),用于导入历史记忆
scene: 显式指定写入路由,跳过 LLM 自动判定。
- None(默认):含 tool 时 LLM 判 chat/working/coding;
无 tool 时直接走 chat
- "chat":强制走 chat 链路(tool 消息会被 strip)
- "working":强制走 coding 链路(通用生产力场景)
- "coding":强制走 coding 链路(软件工程场景)
注意:强制 working / coding 时输入必须含 tool 消息,
否则回退 chat 链路。
"working" 与 "coding" 复用同一种生产力 pipeline 和表结构,
但使用独立 SQLite 文件与向量集合。行为差异为:
1. 抽取规范不同。coding 用 EXTRACT_PROMPT_CODING:要求
分支名逐字保留、说明哪一版赢了哪一版被弃、给精确的
仓相对路径 + 符号名、区分"已验证"与"只是写了"。
2. coding 额外维护版本演进链层(零 LLM 的 sqlite),存下
每处代码每一版的真实 patch。补的是抽取规范本身砍掉的
那类信息 —— 它明文要求"给坐标不要贴代码",而被撤掉那
版的代码不在工作区也不在 HEAD,读不回来。
搜索侧要拿到链块必须也传 scene="coding",见 search()。
workspace_id: Coding memory 路径专用。repo 标识(建议 git remote URL 规范化)。
随经验与事实一起落库,供 search / list 按作用域筛选。chat 路径忽略。
branch: Coding memory 路径专用。分支名。同 workspace_id。chat 路径忽略。
meta: 调用方自定义业务 meta,随本次产出的记忆存储、查询时原样返回。
不 embed、不参与召回、不作过滤条件。
与 metadata 不同:metadata 是旧版内部处理选项,
meta 独立持久化,只做业务透传。
语义为「该次 write 时刻的上下文」,故存为 list——同一节点被
多次写入合并时会追加而非覆盖,详见 get() 返回的 meta 字段。
Returns:
chat 链路:{"success": True, "memory_id": "...", "request_id": "...", "elapsed_ms": ...}
coding 链路:{"success": True, "scene": "productivity", "ops": [...],
"experience_ids": [...], "fact_ops": [...], "fact_ids": [...], ...}
"""
return self._loop_thread.run(
self.async_add(
data, user_id=user_id, agent_id=agent_id, session_id=session_id,
source=source,
metadata=metadata, memory_at=memory_at,
scene=scene,
workspace_id=workspace_id, branch=branch,
request_id=request_id, meta=meta,
embed_override=embed_override,
),
include_runtime_diag=True,
)
def finalize_session(
self,
*,
user_id: str,
agent_id: str = "default_agent",
session_id: str,
) -> Dict[str, Any]:
"""Finalize one caller session into a first-class episode narrative.
The feature is opt-in through MEMORY_SESSION_EPISODE_ENABLED. Calls are
idempotent: the same user/agent/session tuple upserts one deterministic node.
"""
return self._loop_thread.run(
self.async_finalize_session(
user_id=user_id, agent_id=agent_id, session_id=session_id,
),
include_runtime_diag=True,
)
def search(
self,
query: Optional[str] = None,
*,
queries: Optional[List[str]] = None,
scene: Optional[str] = None,
search_mode: str = "default",
user_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
limit: int = 10,
min_score: float = 0.4,
working_fact_min_score: Optional[float] = None,
working_experience_min_score: Optional[float] = None,
schema_min_score: float = 0.4,
schema_limit: int = 10,
intention_limit: int = 0,
created_after: Optional[float] = None,
reader: str = "",
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
) -> Dict[str, Any]:
"""
搜索记忆。
三路召回(chat 路径):
- **Schema 路**: L6_SCHEMA(高层行为/状态 schema)
- **Proactive 路**: L7_INTENTION(主动意图,默认关闭)
- **Normal 路**: L2_FACT + L3_SUMMARY + L4_IDENTITY + L5_KNOWLEDGE 等
用户画像 L0 不再混入 Search;请调用 get_user_profile() 单独获取。
Coding 路径(自动判定):
- 当 queries 上下文判定为 coding 时,走 CodingMemoryStore 的双层召回:
事实层为主索引,命中的事实顺 derived_experience_ids 带出经验,再用同一
向量直查经验层兜底。返回结构含 "scene": "productivity" 和 "facts" /
"experiences" 两个列表。
- 否则走 chat 路径,返回与历史完全一致。
Args:
query: 搜索查询(兼容旧调用;与 queries 二选一)
queries: 多条 queries(最后一条为 target;前置项用于上下文消解)。
default 模式下,Coding 开启时分类并 rewrite;Coding 关闭但
Query Rewrite 开启时只 rewrite/time;两者关闭时零 LLM。
scene: 显式指定检索路由,跳过 LLM 自动判定。与 add() 取值一致:
- None(默认):由 search_mode 决定是否自动判定
- "chat":强制走 chat 召回
- "working":强制走 coding(生产力)召回
- "coding":同上,额外返回版本演进链块
("chain_block" / "chain_used" 两个字段,见下方 Returns)
- "all":chat / working / coding 三路并发召回后用 RRF
融合成一个列表(见下方 Returns)。chat 路用
limit/min_score 等 chat 参数;working/coding 路用各自的
working_fact_min_score / working_experience_min_score。
融合只看每路**自己的名次**:score = 1/(60 + rank),
rank 从 1 起、k=60 为常数(RRF 惯用值),所以不同量纲
的分数不会互相压制;同分时按 chat → working → coding
排。融合后截断到 limit。某一路失败或未启用(coding 关、
lite 模式)只是不贡献结果,记进 "errors",不会让整次
search 失败;没写过的生产力场景直接跳过(不建空库)。
include_sources / exclude_sources 对三路都生效;
created_after / layers 只作用于 chat 路。
(旧别名 "normal"/"productivity" 仍兼容)
search_mode: 搜索执行模式:
- "default"(默认):query="..." 是 light search,直接
chat/normal、零 LLM;显式传 queries=[...] 时由 Coding 与
Query Rewrite 开关共同决定是否分类/改写。
- "agentic":scene=None 时,单 query / 多 queries 都用
一次 LLM 完成三分类、query rewrite 和时间范围解析。
显式 scene 始终直接路由,不调用 LLM。
user_ids: 用户 ID 列表(一级隔离 key,支持跨用户搜索)
agent_ids: Agent ID 列表(空列表 = 搜索所有 agent)
session_ids: Session ID 列表(空列表 = 搜索所有 session)
include_sources: 调用来源白名单;空列表 = 不限。
exclude_sources: 调用来源黑名单;与白名单冲突时黑名单优先。
limit: Normal 路的 topk 数量,默认 10。这也是 MMR 判定用的
top-k:limit < 20(MMR_MAX_TOP_K,定义在
pipelines/_retrieval/diversity.py)时 chat 路自动做 MMR
多样性选择(权重仍取 MEMORY_HYBRID_V2_MMR_LAMBDA);
limit >= 20 时跳过 MMR(O(k²·pool) 的 Jaccard 贪心,
大 k 下代价高、收益小)。重排池是 limit 的若干倍,但判定
只看调用方给的 limit。
min_score: **chat 路专用**的最低分数阈值,默认 0.4。working 路不看
这个值,两层各有自己的阈值(见下两项)。
working_fact_min_score:
working 路事实层(主索引)的阈值,默认 0.30。刻意低于
chat:事实是一句话结论,同样相关度下分数系统性偏低,沿用
0.4 会挡掉明确相关的事实,还会连带把顺链带经验那一路掐断。
working_experience_min_score:
working 路经验层的阈值,顺链和直查共用。经验层 embed
的是 task + search_keys 这类短片段,相似度基线高于事实
层的整句 statement,跟事实共用一个阈值会让经验明显偏松,
所以不传则取 working_fact_min_score + 0.10。
schema_min_score: Schema 路的最低分数阈值,默认 0.4
schema_limit: L6 Schema 路的最大返回数,默认 10
intention_limit: Proactive 路的 topk 数量,默认 0(关闭)
created_after: Unix timestamp (float),只返回 gmt_created >= 此值的记忆
reader: Reader 类型,动态切换。仅支持 legacy / hybrid。
空字符串 = 使用环境变量 HY_MEMORY_READER 或默认 legacy。
workspace_id: Coding 路径专用(chat 路径忽略)。作用域筛选条件:不传则
不筛(全量语义召回),传了则事实层与经验层都只在该
workspace 内召回。过滤在 SQLite 侧做,向量取数按作用域
选择率放大,不会因为过滤掉召回。
branch: 同 workspace_id,但只在 workspace_id 非空时才叠加(与
list_memories 一致),单独传不生效。容 NULL:没记录分支
的经验/事实照样召回,否则老数据会被整片筛掉。
request_id: 可选。App 层预生成以对齐 SEARCH_API_ACCEPTED;不传则 SDK 自生成
layers: 可选。pro/ultra 下只召回指定层级(字符串或
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
强制只召回 L1_RAW。
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
与记忆关系图重排(graph_rerank / episode 扩展),working/
coding 路事实层的 BM25 词面融合重排;结果按召回分排序。
按次生效(随请求对象下传),并发 search 互不影响。
Returns:
chat 路径:{"request_id": "...", "memories": [...], "elapsed_ms": ...}
coding 路径:{"request_id": "...", "scene": "productivity",
"facts": [...], "experiences": [...], "elapsed_ms": ...}
scene="coding" 额外带两个字段:
"chain_block" —— 版本演进链块(多行文本,挂不上时为 "")。
该放在 facts 之前给模型读。
"chain_used" —— 已挂进 chain_block 的 facts 下标列表。
facts **没有**被去重过(不悄悄少给);需要
"块 + 剩余 fact" 的拼法就按这个列表自行过滤。
scene="all":{"request_id", "scene": "all",
"memories": [统一条目...],
"lanes": {"chat": n, "working": n, "coding": n}, # 各路参与融合的候选数
"errors": {lane: "原因"}, # 失败/未启用的路,全成功时为 {}
"elapsed_ms"}
统一条目在各路原字段之上加/覆盖:
"scene" "chat" | "working" | "coding"
"kind" "chat" | "experience" | "fact"
"memory_id" chat 的 memory_id / experience_id / fact_id
"content" chat 正文;经验为 "task\nsolution";事实为 statement
"score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次
"source", "created_at", "updated_at"(秒级 epoch,取不到为 None)
"memory_at" 对话时间(秒级 epoch),各路原有字段,三路同名同格式
chat 路内部名次:normal → schema → proactive 依次排;working/coding 路
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
"""
return self._search_loop_thread.run(self.async_search(
query, queries=queries, scene=scene, search_mode=search_mode,
user_ids=user_ids, agent_ids=agent_ids, session_ids=session_ids,
include_sources=include_sources, exclude_sources=exclude_sources,
limit=limit, min_score=min_score,
working_fact_min_score=working_fact_min_score,
working_experience_min_score=working_experience_min_score,
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader,
workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
embed_override=embed_override,
query_embedding=query_embedding,
rerank=rerank,
))
def get(
self,
memory_id: str,
*,
kind: Optional[MemoryKind] = None,
variant: Optional[MemoryVariant] = None,
) -> Optional[Dict[str, Any]]:
"""
获取单条记忆。
Args:
memory_id: 记忆 ID
kind: 可选 namespace("chat" / "experience" / "fact")。
variant: 可选生产力变体("working" / "coding")。
Returns:
带 kind/variant 的统一 memory dict,或 None(不存在时)。
"""
return self._loop_thread.run(
self.async_get(
memory_id,
kind=kind,
variant=variant,
)
)
def update(
self,
memory_id: str,
content: str,
*,
kind: Optional[MemoryKind] = None,
variant: Optional[MemoryVariant] = None,
task: Optional[str] = None,
) -> Dict[str, Any]:
"""
更新记忆内容。chat / working / coding 都支持,ID 解析与 get() 相同。
Args:
memory_id: 记忆 ID(chat memory_id / experience_id / fact_id)
content: 新内容。chat → 正文;experience → solution(task 不变);
fact → statement。
kind: 可选 namespace("chat" / "experience" / "fact"),消歧用。
variant: 可选生产力变体("working" / "coding"),消歧用。
task: 可选,仅经验:同时替换 task(会重嵌检索 key)。
Returns:
{"success": True, "memory_id": "...", "request_id": "...", "elapsed_ms": ...}
生产力条目额外带 "kind" / "variant"。ID 同时命中多个 namespace 时抛
AmbiguousMemoryIdError。
"""
return self._loop_thread.run(self.async_update(
memory_id, content, kind=kind, variant=variant, task=task,
))
def delete(
self,
memory_id: str,
*,
kind: Optional[MemoryKind] = None,
variant: Optional[MemoryVariant] = None,
) -> Dict[str, Any]:
"""
删除单条记忆。chat / working / coding 都支持,ID 解析与 get() 相同。
Args:
memory_id: 记忆 ID(chat memory_id / experience_id / fact_id)
kind: 可选 namespace("chat" / "experience" / "fact"),消歧用。
variant: 可选生产力变体("working" / "coding"),消歧用。
Returns:
{"success": True, "deleted_count": 1, "request_id": "...", "elapsed_ms": ...}
生产力条目额外带 "kind" / "variant"。ID 同时命中多个 namespace 时抛
AmbiguousMemoryIdError。
"""
return self._loop_thread.run(
self.async_delete(memory_id, kind=kind, variant=variant)
)
def delete_all(
self,
*,
user_id: str = "",
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
删除用户的记忆。
默认删除该用户的所有记忆(agent_ids/session_ids 都不传时)。
可通过 agent_ids/session_ids 缩小删除范围。
Args:
user_id: 用户 ID(一级隔离 key)
agent_ids: Agent ID 列表(空列表 = 所有 agent)
session_ids: Session ID 列表(空列表 = 所有 session)
Returns:
{"success": True, "deleted_count": N, "request_id": "...", "elapsed_ms": ...}
"""
return self._loop_thread.run(
self.async_delete_all(user_id=user_id, agent_ids=agent_ids, session_ids=session_ids)
)
def list_memories(
self,
*,
user_id: str = "",
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0,
order: str = "desc",
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
user_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
with_evolution: bool = False,
scene: Optional[str] = None,
) -> Dict[str, Any]:
"""
列出用户的记忆(同步)。
从向量库直接读取,只返回 ACTIVE 状态的记忆。
按 created_at 排序,支持分页。
Args:
user_id: 用户 ID
agent_id: 可选,限定某个 agent
limit: 每页条数(默认 100)
offset: 偏移量(默认 0)
order: 排序方式,"desc"(默认,最新在前)或 "asc"
workspace_id: 可选(默认空)。非空则直接走 coding memory 列表路径,
按 workspace_id 过滤,返回 {"scene": "productivity", ...}。
branch: 可选(默认空)。仅在 workspace_id 非空时叠加 branch 精确过滤。
user_ids: 可选,多用户批量列举。非空时优先于 user_id,返回并集
(分页在合并排序后统一施加)。
agent_ids: 可选,多 agent 批量列举。非空时优先于 agent_id。
session_ids: 可选,只返回指定 session。
include_sources: 可选,只返回指定来源。
exclude_sources: 可选,排除指定来源;与 include_sources 冲突时排除优先。
layers: 可选,只返回指定层级(字符串或 MemoryLayer)。不传时返回
L1_RAW 以外的全部层级,与改动前一致。
statuses: 可选,只返回指定生命周期(字符串或 MemoryStatus)。不传时
保持兼容,只返回 ACTIVE。可显式读取 SUPERSEDED / SHADOW /
NEGATED / CONFLICTED / ARCHIVED;物理删除的节点不会返回。
with_evolution: 可选,为当前页条目附加 evolution_chain(默认关闭)。
scene: 可选,列哪一类记忆:"chat" | "coding" | "working" | "all"。
"coding"/"working" 列该类全部经验(workspace_id 可再收窄,
include_sources/exclude_sources 按 source 列过滤),
按 updated_at 倒序,total 为过滤后的真实总数。
"all" 把 chat(向量库节点,与 memory_stats 的 chat 同口径)
与 working/coding 经验合并,按更新时间倒序(chat 取
gmt_modified,缺省用 gmt_created;经验取 updated_at)分页,
total 为三路之和。include_sources/exclude_sources/statuses
作用于三路(经验只有 ACTIVE,statuses 不含 ACTIVE 时两路为空);
agent/session/layers 只作用于 chat,workspace_id/branch 只作用
于经验。只支持 order="desc"。不传 scene 时保持原行为。
Returns:
chat 路径: {"vdb": {...}, "graph": {...} | 省略, "elapsed_ms": ...}
coding 路径: {"scene": "productivity", "experiences": [...], ...}
scene="all": {"scene": "all", "memories": [...], "total",
"totals": {"chat", "working", "coding"}, "errors": {...},
"limit", "offset", "request_id", "elapsed_ms"}
每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content"
(经验为 "task\nsolution")、"source"、"status"、"created_at"、
"updated_at"(秒级 epoch),以及该路原有字段(含对话时间
"memory_at",秒级 epoch,三路同名同格式)。
"""
return self._loop_thread.run(
self.async_list_memories(
user_id=user_id, agent_id=agent_id,
user_ids=user_ids, agent_ids=agent_ids,
session_ids=session_ids,
include_sources=include_sources,
exclude_sources=exclude_sources,
limit=limit, offset=offset, order=order,
workspace_id=workspace_id, branch=branch,
layers=layers, statuses=statuses, with_evolution=with_evolution,
scene=scene,
)
)
def memory_stats(
self,
*,
user_id: str = "",
statuses: Optional[List[Any]] = None,
) -> Dict[str, Any]:
"""
每类记忆的数量与最新时间(同步)。纯聚合,不返回正文。
Returns:
{
"total": chat + coding + working 的条数,
"scenes": {
"chat": {"count", "latest_created_at", "latest_updated_at"},
"coding": {"count", "latest_created_at", "latest_updated_at", "facts"},
"working": {同 coding},
},
"request_id", "elapsed_ms",
}
时间均为秒级 epoch,没有记忆时为 None。chat 不含 L1_RAW;
coding/working 的 count 是经验条数,facts 是 active 事实数(不计入 total)。
"""
return self._loop_thread.run(
self.async_memory_stats(user_id=user_id, statuses=statuses)
)
def get_user_basic_info(
self,
*,
user_id: str = "",
) -> Dict[str, Any]:
"""返回用户的 L0 basic info 记忆(同步)。
存在演化链时以链头为代表返回并附 `evolution_chain`,与 search / list 一致。
Returns:
{"request_id", "memories": [...], "elapsed_ms"}
"""
return self._loop_thread.run(
self.async_get_user_basic_info(user_id=user_id)
)
def get_user_profile(
self,
*,
user_id: str,
) -> Dict[str, Any]:
"""Return the user's single assembled L0 profile.
This is the public replacement for the L0 row previously mixed into
Search results. It performs no embedding or LLM call.
In Ultra mode, the active balanced catalog and specific supplement are
appended to the same profile content.
Returns:
{"request_id": str, "profile": dict | None, "elapsed_ms": float}
"""
return self._loop_thread.run(
self.async_get_user_profile(user_id=user_id)
)
def clone_user(
self,
src_user_id: str,
dst_user_id: str,
*,
agent_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
克隆用户记忆。
将 src_user_id 下的全部记忆深拷贝到 dst_user_id 下,
两者此后完全独立互不影响。
Args:
src_user_id: 源用户 ID
dst_user_id: 目标用户 ID
agent_id: 可选,限定克隆某个 agent 下的记忆
Returns:
{"success": True, "request_id": "...", "cloned_count": N,
"by_layer": {"l0_basic_info": 1, ...}, "elapsed_ms": ...}
"""
return self._loop_thread.run(
self.async_clone_user(
src_user_id, dst_user_id, agent_id=agent_id,
)
)
# ================================================================
# System 2 Evolve API
# ================================================================
def evolve(
self,
user_id: str,
agent_id: str = "default_agent",
max_batches: Optional[int] = None,
) -> Dict[str, Any]:
"""
手动触发 System 2 认知加工(ultra 模式专用,同步阻塞直到完成)。
evolve 一次 = 对该 user 的 VDB 全量跑一次 System 2(Schema 归纳 → L6_SCHEMA
+ cross-domain sweeper)。System 2 的输入是 VDB 全量扫描,与「期间 add 了几次」
无关,因此 evolve 不消费写入队列、跑一次完整认知周期。
非幂等:每次 evolve 看到的是当前最新 VDB 全量,连续多次 evolve 有意义
(add 一批 → evolve → 再 add 一批 → 再 evolve)。
注意:意图(L7_INTENTION)由 System 1 在 add/write 时即时抽取落库(pro/ultra
模式都有),不在 evolve 里处理。evolve 只负责 System 2 的 Schema 层。
触发时机由调用方控制:System 2 不在 add/write 里自动触发,也无进程内队列。
典型用法:批量 add()(只跑 S1)→ 调一次 evolve() 做全量 Schema 归纳。
仅 ultra 模式可调用。lite/pro 模式调用会抛出 RuntimeError。
Args:
user_id: 用户 ID
agent_id: Agent ID
Returns:
{"success": True, "request_id": "...", "tasks_processed": 1, "results": {...},
"cross_domain_sweeper": {...}, "elapsed_ms": ...}
"""
if self._mode != "ultra":
raise RuntimeError(
f"evolve() requires mode='ultra', but this client is mode='{self._mode}'. "
f"Create a new client with HyMemoryClient(mode='ultra') to use System 2."
)
return self._loop_thread.run(
self.async_evolve(
user_id=user_id, agent_id=agent_id, max_batches=max_batches,
)
)
@_ensure_internal_loop
async def async_evolve(
self,
user_id: str,
agent_id: str = "default_agent",
max_batches: Optional[int] = None,
) -> Dict[str, Any]:
"""手动触发 System 2 认知加工(异步)"""
t0 = __import__("time").perf_counter()
# 获取 System2Writer
try:
writer = await self._registry.get_system2_writer()
except (ValueError, KeyError):
return {
"success": False,
"error": "System2Writer not available.",
"tasks_processed": 0,
}
# 检查是否支持 evolve
if not hasattr(writer, "evolve"):
return {
"success": False,
"error": f"Writer '{writer.VERSION}' does not support evolve()",
"tasks_processed": 0,
}
result = await writer.evolve(
user_id=user_id, agent_id=agent_id, max_batches=max_batches,
)
result["elapsed_ms"] = round((__import__("time").perf_counter() - t0) * 1000, 2)
return result
def sweep(
self,
user_ids: List[str],
agent_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
手动触发 cross-domain sweeper(同步)。
独立于 evolve 流程,不执行 S2 Agent,只做:
1. 补齐 schema 的 beh abstraction + beh_embedding
2. 碰撞扫描 + core 归纳
对每个 user_id × agent_id 组合执行一次 sweeper。
Args:
user_ids: SDK user_id 列表
agent_ids: Agent ID 列表(默认 ["default_agent"])
Returns:
{"success": True, "results": [{...}, ...]}
"""
if self._mode != "ultra":
raise RuntimeError(
f"sweep() requires mode='ultra', but this client is mode='{self._mode}'."
)
return self._loop_thread.run(
self.async_sweep(user_ids=user_ids, agent_ids=agent_ids)
)
@_ensure_internal_loop
async def async_sweep(
self,
user_ids: List[str],
agent_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""手动触发 cross-domain sweeper(异步)
对每个 user_id × agent_id 组合执行一次 sweeper。
"""
t0 = __import__("time").perf_counter()
try:
writer = await self._registry.get_system2_writer()
except (ValueError, KeyError):
return {"success": False, "error": "System2Writer not available.", "results": []}
effective_agents = agent_ids or ["default_agent"]
results = []
for uid in user_ids:
for aid in effective_agents:
r = await writer.run_sweeper(user_id=uid, agent_id=aid)
results.append({"user_id": uid, "agent_id": aid, **r})
elapsed_ms = round((__import__("time").perf_counter() - t0) * 1000, 2)
return {"success": True, "results": results, "elapsed_ms": elapsed_ms}
# ================================================================
# System Metrics
# ================================================================
def get_metrics(self, minutes: int = 5) -> Dict[str, Any]:
"""
获取系统整体负载指标。
Args:
minutes: 查询最近 N 分钟的数据(默认 5 分钟)
Returns:
{
"uptime_seconds": float,
"requests": {"total", "active_sys1", "active_sys2", "queued_sys2", "completed", "failed"},
"avg_latency_ms": {"sys1_waiting", "sys1_l1_process", "sys1_workflow", ...},
"throughput": {"sys1_completed_last_60s", "sys2_completed_last_60s"},
"storage_ops": {"vdb_ops_total", "vdb_ops_avg_ms", "graph_ops_total", "graph_ops_avg_ms"},
}
"""
return self._loop_thread.run(self.async_get_metrics(minutes=minutes))
@_ensure_internal_loop
async def async_get_metrics(self, minutes: int = 5) -> Dict[str, Any]:
"""获取系统整体负载指标(异步)"""
from .metrics import MetricsCollector
return await MetricsCollector.get().get_snapshot(minutes=minutes)
def _stop_owned_loops(self) -> None:
"""Stop every event-loop thread this client owns.
Solo clients create both a write loop and a search loop. Stopping only
the write loop left the search thread (and a self-wakeup timer every
50ms) alive for the process lifetime. Shared runtimes own their loops
and manage them through ``SharedRuntime.aclose()``.
"""
if not getattr(self, "_owns_runtime", False):
return
seen = set()
for loop_thread in (
getattr(self, "_loop_thread", None),
getattr(self, "_search_loop_thread", None),
):
if loop_thread is None or id(loop_thread) in seen:
continue
seen.add(id(loop_thread))
loop_thread.stop()
def close(self):
"""关闭客户端,释放所有资源"""
try:
if self._registry is not None:
self._loop_thread.run(self._close_async())
finally:
self._stop_owned_loops()
def history(self, memory_id: str) -> List[Dict[str, Any]]:
"""
查询某条记忆的变更历史。
Args:
memory_id: 记忆 ID
Returns:
按时间排序的历史记录列表,每条包含 event/old_memory/new_memory/created_at 等字段。
如果未启用历史记录,返回空列表。
"""
return self._loop_thread.run(self.async_history(memory_id))
def get_recent_history(
self,
user_id: str = "",
agent_ids: Optional[List[str]] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""
查询最近的操作历史。
Args:
user_id: 用户 ID
agent_ids: Agent ID 列表(空列表 = 查所有 agent 的历史)
limit: 返回数量上限
Returns:
按时间倒序的最近操作列表。
如果未启用历史记录,返回空列表。
"""
return self._loop_thread.run(
self.async_get_recent_history(user_id=user_id, agent_ids=agent_ids, limit=limit)
)
def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
"""Return the exact durable outcome for one idempotent write request."""
return self._loop_thread.run(self.async_get_write_outcome(request_id))
async def _close_async(self):
if self._owns_runtime:
from .metrics import MetricsCollector
await MetricsCollector.get().stop_background_tasks()
# 先关 coding 链路(不依赖其他组件)
# ⚠️ 遍历 _coding_stores 而不是只关 self._coding_store —— 后者只是"最近一次
# 用的那份",两个变体混用过时另一份会漏掉(sqlite 连接和向量库句柄泄漏)。
for _v, _st in list(getattr(self, "_coding_stores", {}).items()):
try:
await _st.close()
except Exception as e:
logger.warning(f"[close] coding store close failed (variant={_v}): {e}")
if getattr(self, "_coding_stores", None) is not None:
self._coding_stores.clear()
if getattr(self, "_coding_writers", None) is not None:
self._coding_writers.clear()
if getattr(self, "_coding_store", None) is not None:
self._coding_store = None
self._coding_writer = None
self._coding_llm_provider = None
# 链层:纯 sqlite,没有异步资源
if getattr(self, "_chain_store", None) is not None:
try:
self._chain_store.close()
except Exception as e:
logger.warning(f"[close] chain store close failed: {e}")
self._chain_store = None
if self._registry:
await self._registry.close()
self._registry = None
if self._graph_store:
await self._graph_store.close()
self._graph_store = None
if self._history_store:
await self._history_store.close()
self._history_store = None
# cache:solo 模式下 client 拥有,关;shared 模式下 runtime 拥有,留给 runtime.aclose()
if self._cache and self._owns_runtime:
await self._cache.close()
self._cache = None
elif self._cache and not self._owns_runtime:
# shared 模式:仅断引用,不真正关 pool
self._cache = None
logger.info("HyMemoryClient closed")
# ================================================================
# Delete helpers — 演化链修复 + Graph evidence 清理
# ================================================================
async def _repair_evolution_chain_on_delete(self, node) -> None:
"""
删除节点前修复演化链。
场景 1: 删除链头(is_latest=True + supersedes 非空)
→ 将 supersedes 列表中的前驱恢复为 is_latest=True + ACTIVE
→ 从前驱的 superseded_by 中移除本节点 ID
场景 2: 删除链中间节点(is_latest=False + superseded_by 非空 + supersedes 非空)
→ 将前驱的 superseded_by 改为指向后继
→ 将后继的 supersedes 改为指向前驱(跳过本节点)
场景 3: 删除链尾(is_latest=False + supersedes 为空)
→ 只需从后继的 supersedes 中移除本节点 ID
"""
from .models.memory import MemoryStatus
node_id = node.node_id
supersedes = node.supersedes or [] # 本节点取代的旧节点
superseded_by = node.superseded_by or [] # 取代本节点的新节点
is_latest = node.is_latest
if not supersedes and not superseded_by:
return # 不在任何链上
try:
# 场景 1: 删除链头 — 恢复前驱
if is_latest and supersedes:
for pred_id in supersedes:
pred = await self._vector_store.get_by_id(pred_id)
if pred is None:
continue
# 从前驱的 superseded_by 中移除本节点
pred_sb = [x for x in (pred.superseded_by or []) if x != node_id]
# 如果前驱没有其他后继,恢复为链头
if not pred_sb:
await self._vector_store.update_payload(pred_id, {
"is_latest": True,
"status": MemoryStatus.ACTIVE.value,
"superseded_by": [],
})
logger.info(f"[delete] chain repair: restored {pred_id} as chain head")
else:
await self._vector_store.update_payload(pred_id, {
"superseded_by": pred_sb,
})
# 场景 2: 删除链中间 — 跳接前驱和后继
elif not is_latest and supersedes and superseded_by:
# 后继的 supersedes: 把本节点替换为本节点的前驱
for succ_id in superseded_by:
succ = await self._vector_store.get_by_id(succ_id)
if succ is None:
continue
succ_sup = list(succ.supersedes or [])
if node_id in succ_sup:
succ_sup.remove(node_id)
succ_sup.extend(supersedes)
await self._vector_store.update_payload(succ_id, {
"supersedes": succ_sup,
})
# 前驱的 superseded_by: 把本节点替换为本节点的后继
for pred_id in supersedes:
pred = await self._vector_store.get_by_id(pred_id)
if pred is None:
continue
pred_sb = list(pred.superseded_by or [])
if node_id in pred_sb:
pred_sb.remove(node_id)
pred_sb.extend(superseded_by)
await self._vector_store.update_payload(pred_id, {
"superseded_by": pred_sb,
})
logger.info(f"[delete] chain repair: bridged {supersedes} → {superseded_by}")
# 场景 3: 删除链尾 — 从后继的 supersedes 中移除
elif not is_latest and not supersedes and superseded_by:
for succ_id in superseded_by:
succ = await self._vector_store.get_by_id(succ_id)
if succ is None:
continue
succ_sup = [x for x in (succ.supersedes or []) if x != node_id]
await self._vector_store.update_payload(succ_id, {
"supersedes": succ_sup,
})
except Exception as e:
logger.warning(f"[delete] chain repair failed for {node_id}: {e}")
async def _cleanup_graph_evidence(self, deleted_node_id: str) -> bool:
"""
清理已删除 VDB 节点在 Graph 中的 evidence 引用。
当一个 VDB 节点被删除后,如果它作为 VdbRef 影子节点被某个
Schema/Intention 通过 DERIVED_FROM 引用,需要清理悬空引用。
注意:VdbRef 是独立的 node table,不能用 delete_node(那是删 Memory)。
直接用底层 execute 删 VdbRef + DERIVED_FROM 边。
"""
if self._graph_store is None:
return True
try:
gs = self._graph_store
# Kuzu backend
if hasattr(gs, '_execute') and hasattr(gs, '_available'):
if not gs._available:
return False
gs._execute(
"MATCH (v:VdbRef {node_id: $nid}) DETACH DELETE v;",
{"nid": deleted_node_id},
)
# Neo4j backend
elif hasattr(gs, '_run_write'):
await gs._run_write(
"MATCH (v:VdbRef {node_id: $nid}) DETACH DELETE v",
{"nid": deleted_node_id},
)
logger.debug(f"[delete] VdbRef {deleted_node_id} cleaned from Graph")
return True
except Exception as e:
logger.debug(f"[delete] VdbRef cleanup for {deleted_node_id}: {e}")
return False
# ================================================================
# 异步 API
# ================================================================
async def _claim_write_outcome(
self,
*,
request_id: str,
user_id: str,
agent_id: str,
session_id: str,
operation: str,
) -> Optional[Dict[str, Any]]:
existing = await self._cache.claim_write_outcome(
request_id,
user_id,
agent_id,
session_id,
operation,
)
if existing is None:
return None
expected = (user_id, agent_id, session_id, operation)
actual = tuple(
str(existing.get(key) or "")
for key in ("user_id", "agent_id", "session_id", "operation")
)
if actual != expected:
raise ValueError("request_id is already bound to another write scope")
result = existing.get("result")
if isinstance(result, dict) and existing.get("status") in {"succeeded", "failed"}:
return {**result, "replayed": True}
return {
"success": False,
"request_id": request_id,
"outcome_status": str(existing.get("status") or "unknown"),
"error_code": "write_outcome_not_terminal",
"error_message": "write outcome is not terminal; query get_write_outcome",
"replayed": True,
}
async def _finish_write_outcome(
self,
request_id: str,
status: str,
result: Dict[str, Any],
) -> None:
if not await self._cache.finish_write_outcome(request_id, status, result):
raise RuntimeError("write outcome disappeared before completion")
async def _mark_write_outcome_unknown(self, request_id: str) -> None:
await self._finish_write_outcome(
request_id,
"unknown",
{
"success": False,
"request_id": request_id,
"outcome_status": "unknown",
"error_code": "write_outcome_unknown",
},
)
@_ensure_internal_loop
async def async_add(
self,
data: AddInput,
*,
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "default_session",
source: str = "default",
metadata: Optional[Dict[str, Any]] = None,
memory_at: Optional[datetime] = None,
scene: Optional[str] = None,
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
request_id: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""写入记忆(异步)
embed_override: 本次写入的 embedding 端点覆盖(不改 model/dims,只换接入点)。
dict,可含 base_url / api_key / extra_headers / timeout;None = 用 client 配置。
可用于按请求切换任意 OpenAI-compatible embedding endpoint。
"""
request_id = request_id or str(uuid.uuid4())
replay = await self._claim_write_outcome(
request_id=request_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
operation="add",
)
if replay is not None:
return replay
try:
if embed_override:
with embed_endpoint_override(
base_url=embed_override.get("base_url"),
api_key=embed_override.get("api_key"),
extra_headers=embed_override.get("extra_headers"),
timeout=embed_override.get("timeout"),
):
result = await self._async_add_impl(
data, user_id=user_id, agent_id=agent_id, session_id=session_id,
source=source,
metadata=metadata, memory_at=memory_at,
scene=scene, workspace_id=workspace_id, branch=branch,
request_id=request_id, meta=meta,
)
else:
result = await self._async_add_impl(
data, user_id=user_id, agent_id=agent_id, session_id=session_id,
source=source,
metadata=metadata, memory_at=memory_at,
scene=scene, workspace_id=workspace_id, branch=branch,
request_id=request_id, meta=meta,
)
except BaseException:
await self._mark_write_outcome_unknown(request_id)
raise
await self._finish_write_outcome(
request_id,
"succeeded" if result.get("success") is True else "failed",
result,
)
return result
@_ensure_internal_loop
async def async_finalize_session(
self,
*,
user_id: str,
agent_id: str = "default_agent",
session_id: str,
) -> Dict[str, Any]:
"""Async counterpart of :meth:`finalize_session`."""
series = bool(getattr(self._config.recall, "series_episode_enabled", False))
dynamic = bool(
getattr(self._config.recall, "dynamic_episode_enabled", False) or series
)
if not (
getattr(self._config.recall, "session_episode_enabled", False) or dynamic
):
return {"success": False, "status": "disabled",
"error_message": "episode finalization is disabled"}
if not user_id or not session_id:
raise ValueError("user_id and session_id are required")
from .agent.episode_summary import (
finalize_dynamic_episode_capsules,
finalize_series_episode_containers,
finalize_session_episode,
)
writer = await self._registry.get_writer()
vector_store = await writer._get_vector_store()
finalize = (
finalize_dynamic_episode_capsules if dynamic
else finalize_session_episode
)
finalize_kwargs = dict(
vector_store=vector_store,
embed_service=writer.embed_service,
llm_provider=writer.llm_provider,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
)
if finalize is finalize_dynamic_episode_capsules:
finalize_kwargs["summary_concurrency"] = getattr(
self._config.recall, "dynamic_episode_summary_concurrency", 3,
)
from .core.workload import episode_slot, workload_scope
async with episode_slot(), workload_scope("derived"):
result = await finalize(**finalize_kwargs)
if series and result.get("status") != "failed":
try:
result["series_episode"] = await finalize_series_episode_containers(
vector_store=vector_store,
graph_store=self._graph_store,
user_id=user_id,
agent_id=agent_id,
)
except Exception as exc:
logger.warning("[series-episode] finalize failed: %s", exc)
result["series_episode"] = {
"status": "failed", "error": str(exc),
}
return {"success": result.get("status") != "failed", **result}
async def _async_add_impl(
self,
data: AddInput,
*,
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "default_session",
source: str = "default",
metadata: Optional[Dict[str, Any]] = None,
memory_at: Optional[datetime] = None,
scene: Optional[str] = None,
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
request_id: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""写入记忆(异步)
Args:
scene: 显式指定写入路由("chat" / "working" / "coding");
None = 自动判定。详见 add() docstring。
workspace_id / branch: Coding memory 路径专用,详见 add() docstring。
meta: 调用方自定义业务 meta,透传存储,详见 add() docstring。
"""
from .utils.source_filter import normalize_source
source = normalize_source(source)
# 归一化显式 scene(None = 自动判定)
scene_forced = self._normalize_scene(scene)
# coding 链路的变体("working" | "coding")。显式 scene 直接决定;
# scene=None 时由三分类 judge 回填,不能默认吞成 working。
scene_variant = self._scene_variant(scene)
# 生成 request_id 并注入日志上下文(队列消费可传入固定 id 保证幂等与追溯)
request_id = request_id or str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
content, chat_messages, content_type = self._parse_input(data)
uid = user_id
# 纯 input token 数(tiktoken 精确估算;不含 system/few-shot/历史等 prompt 开销)
from .utils.token_count import count_input_tokens
input_tokens = count_input_tokens(content, chat_messages)
# ────────────────────────────────────────────────────────────
# Coding 链路分流
# scene 显式指定时跳过 LLM 判定:
# scene="chat" → 强制 chat(strip tool 后走 chat 链)
# scene="coding" → 强制 coding(须含 tool 消息,否则回退 chat)
# scene=None(默认)→ 自动三分类:
# 不含 tool: O(1) 短路 → chat 链
# 含 tool 但判 chat: strip tool 后走 chat 链
# 含 tool 且判 working/coding: 走对应 variant 的生产力链路
# 关键:不影响现有 chat 路径
# ────────────────────────────────────────────────────────────
coding_available = (
getattr(self, "_mode", "pro") != "lite"
and content_type == "messages"
and getattr(self._config, "coding", None) is not None
and self._config.coding.enable
)
if scene_forced == "chat":
# 强制 chat:跳过一切 coding 判定,strip tool 消息后走 chat 链
if content_type == "messages" and has_any_tool_message(chat_messages):
logger.info("[add] scene=chat forced; stripping tool messages")
chat_messages = strip_tool_messages(chat_messages)
elif coding_available and (
scene_forced == "coding" or has_any_tool_message(chat_messages)
):
has_tool = has_any_tool_message(chat_messages)
if scene_forced == "coding" and not has_tool:
# 强制 coding 但无 tool 消息:无法抽 coding memory,回退 chat
logger.warning(
"[add] scene=coding forced but no tool messages found; "
"falling back to chat path"
)
is_coding = False
elif scene_forced == "coding":
# 显式指定 coding:跳过 LLM judge
logger.info("[add] scene=coding forced; skip LLM judge")
is_coding = True
else:
# 自动判定(scene=None,含 tool 消息)
# lazy 初始化 coding LLM provider(用于 judge)
if self._coding_llm_provider is None:
from .agent.llm_provider import LLMProvider
self._coding_llm_provider = LLMProvider(self._config)
try:
judged_scene = await classify_messages_scene(
chat_messages, self._coding_llm_provider,
cache=self._cache,
request_id=request_id,
user_id=uid,
agent_id=agent_id or "default_agent",
session_id=session_id or "",
)
is_coding = judged_scene != "chat"
if is_coding:
scene_variant = judged_scene
except Exception as e:
logger.warning(f"[add] coding judge failed, fallback to chat: {e}")
is_coding = False
if is_coding:
logger.info(
f"[add] routing to PRODUCTIVITY path variant={scene_variant}: "
f"user={uid} agent_id={agent_id} "
f"workspace_id={workspace_id!r} branch={branch!r}"
)
writer = await self._get_coding_writer(variant=scene_variant)
resp = await writer.write(
chat_messages,
user_id=uid,
agent_id=agent_id,
workspace_id=workspace_id,
branch=branch,
session_id=session_id,
request_id=request_id,
source=source,
memory_at=memory_at,
)
# Public receipt always identifies the productivity namespace.
resp["variant"] = scene_variant
# 版本演进链层(scene="coding" 独有)。放在 writer.write 之后、返回
# 之前,因为它只依赖对话原文,与向量层写入结果无关。
# best-effort:这层挂不上不影响本次 add 的向量层产出。
if scene_variant == "coding":
resp["chain_rounds"] = self._observe_chain(
chat_messages, user_id=uid,
workspace_id=workspace_id, branch=branch,
)
# 同步刷新 elapsed
resp["elapsed_ms"] = round((time.perf_counter() - t0) * 1000, 2)
resp["input_tokens"] = input_tokens
# 与 chat 链路对齐:chat 总是返回单数 memory_id;coding 双层 writer
# 返回复数 experience_ids(一次 add 通常 0~1 条 ADD)。补一个单数
# memory_id 取首个 experience_id,让调用方无需区分 scene 即可读
# resp["memory_id"]。
_coding_eids = resp.get("experience_ids") or []
resp["memory_id"] = _coding_eids[0] if _coding_eids else None
return resp
# 判定为 chat:strip tool 消息再继续
if has_tool:
logger.info(
"[add] classified as CHAT; stripping tools"
)
chat_messages = strip_tool_messages(chat_messages)
if content_type == "messages":
preview = f"messages({len(chat_messages)} turns)"
logger.info(
f"[add] writing {preview} user={uid} "
f"agent_id={agent_id} session_id={session_id} mode={self._mode}"
)
logger.debug(f"[add] messages detail: {[m.to_dict() for m in chat_messages]}")
else:
logger.info(
f"[add] writing text({len(content)} chars) user={uid} "
f"agent_id={agent_id} session_id={session_id} mode={self._mode}"
)
logger.debug(f"[add] text content={content!r}")
# mode → agent_mode 映射
# lite: 不调 LLM agent
# pro/ultra: 调 LLM agent 做事实提取
agent_mode = "disabled" if self._mode == "lite" else "full"
extra = dict(metadata) if metadata else {}
extra["agent_mode"] = agent_mode
extra["mode"] = self._mode
write_req = WriteRequest(
content=content,
messages=chat_messages,
user_id=uid,
agent_id=agent_id,
source=source,
session_id=session_id,
content_type=content_type,
extra=extra,
memory_at=memory_at,
# 显式透传 request_id(本地变量,不受 contextvar 污染)作为整条写入链路
# 落库 memory_ops / WRITE_SUMMARY / pipeline_logs 的单一真相源。
request_id=request_id,
client_meta=meta,
)
# mode → writer:
# lite/pro → MemoryWriter (区别在 agent_mode)
# ultra → System2Writer(write 只执行 System 1;System 2 由 evolve() 触发)
if self._mode == "ultra":
writer = await self._registry.get_system2_writer()
else:
writer = await self._registry.get_writer()
logger.debug(f"[add] using writer: {writer.VERSION} (mode={self._mode})")
result: WriteResponse = await writer.write(write_req)
resp = {
"success": result.success,
"scene": "chat",
"memory_id": result.memory_id,
"request_id": request_id,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_code": result.error_code if result.error_code else None,
"error_message": result.error_message or None,
}
# timing breakdown
_timing = result.extra.get("timing")
if _timing:
resp["timing"] = _timing
# reconcile op counts + stored memory ids (analytics passthrough)
if result.extra.get("reconcile_ops"):
resp["ops_summary"] = result.extra["reconcile_ops"]
if result.extra.get("agent_stored_ids"):
resp["memory_ids"] = result.extra["agent_stored_ids"]
_attach_write_diagnostics(resp, result.extra)
# token usage:纯 input(tiktoken)+ extract / reconcile 两条 LLM 链路的
# prompt/completion 消耗(lite 模式无 LLM,extract/reconcile 为 0)。
resp["input_tokens"] = input_tokens
resp["extract_tokens"] = result.extra.get(
"extract_tokens", {"prompt": 0, "completion": 0, "total": 0}
)
resp["reconcile_tokens"] = result.extra.get(
"reconcile_tokens", {"prompt": 0, "completion": 0, "total": 0}
)
if result.success:
logger.info(f"[add] done: memory_id={result.memory_id} elapsed={result.elapsed_ms:.0f}ms")
# 记录历史(非阻塞,失败仅 warning)
if self._history_store is not None:
try:
isolation_key = self._build_isolation_key(uid, agent_id)
# 从 content 或 messages 中提取用于记录的文本。messages 与 L1_RAW
# 同一规则先裁剪再落审计库(工具输出 / 长回复不原样长期保存)。
from .utils.raw_trim import trim_raw_messages
history_content = content or "; ".join(
f"{m.role}: {m.content}"
for m in trim_raw_messages(
chat_messages,
tool_max_bytes=getattr(
getattr(self._config, "coding", None),
"tool_result_max_bytes", None,
),
)
)
await self._history_store.record_add(
memory_id=result.memory_id,
content=history_content,
layer=result.layer or "",
isolation_key=isolation_key,
)
except Exception as e:
logger.warning(f"[add] history record failed: {e}")
else:
logger.warning(f"[add] failed: {result.error_message}")
return resp
def add_extracted(
self,
contents: Union[str, List[str]],
*,
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "default_session",
source: str = "default",
metadata: Optional[Dict[str, Any]] = None,
memory_at: Optional[datetime] = None,
request_id: Optional[str] = None,
) -> Dict[str, Any]:
"""写入「调用方已抽取好的」记忆:跳过 extract,直接进 reconcile。
适用场景:调用方(如 openclaw memory_add tool、上游 agent)已经把对话
抽象成结论性的 memory content,不需要 SDK 再跑一遍 LLM extract。SDK 仍会
执行完整 reconcile(与已有记忆判重 / 合并 / 矛盾演化),并落 L2_FACT。
与 add() 的区别:
- 不调 LLM extractor(省一次 LLM 调用、更快、更省 token)。
- 不写 L1_RAW 原始节点(content 本身即事实)。
- contents 每条视为一条独立事实,多条会一起进 reconcile 互相判重。
Args:
contents: 单条事实字符串,或多条事实字符串列表。
其余参数语义同 add()。
Returns:
{"success", "memory_id", "memory_ids", "request_id", "elapsed_ms",
"ops_summary", ...}
"""
return self._loop_thread.run(
self.async_add_extracted(
contents, user_id=user_id, agent_id=agent_id,
session_id=session_id, source=source, metadata=metadata,
memory_at=memory_at, request_id=request_id,
),
include_runtime_diag=True,
)
@_ensure_internal_loop
async def async_add_extracted(
self,
contents: Union[str, List[str]],
*,
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "default_session",
source: str = "default",
metadata: Optional[Dict[str, Any]] = None,
memory_at: Optional[datetime] = None,
request_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Idempotent async wrapper for extracted-memory writes."""
request_id = request_id or str(uuid.uuid4())
replay = await self._claim_write_outcome(
request_id=request_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
operation="add_extracted",
)
if replay is not None:
return replay
try:
result = await self._async_add_extracted_impl(
contents,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
source=source,
metadata=metadata,
memory_at=memory_at,
request_id=request_id,
)
except BaseException:
await self._mark_write_outcome_unknown(request_id)
raise
await self._finish_write_outcome(
request_id,
"succeeded" if result.get("success") is True else "failed",
result,
)
return result
async def _async_add_extracted_impl(
self,
contents: Union[str, List[str]],
*,
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "default_session",
source: str = "default",
metadata: Optional[Dict[str, Any]] = None,
memory_at: Optional[datetime] = None,
request_id: Optional[str] = None,
) -> Dict[str, Any]:
"""写入已抽取好的记忆(异步);跳过 extract,直接 reconcile。详见 add_extracted()。"""
request_id = request_id or str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
from .utils.source_filter import normalize_source
source = normalize_source(source)
if isinstance(contents, str):
content_list = [contents]
else:
content_list = list(contents or [])
content_list = [c.strip() for c in content_list if isinstance(c, str) and c.strip()]
if not content_list:
return {
"success": False,
"request_id": request_id,
"error_code": 400,
"error_message": "contents is required (str or non-empty List[str])",
}
from .utils.token_count import count_input_tokens
input_tokens = sum(count_input_tokens(c, None) for c in content_list)
extra = dict(metadata) if metadata else {}
extra["extracted_contents"] = content_list
extra["agent_mode"] = "extracted"
extra["mode"] = self._mode
write_req = WriteRequest(
content=content_list[0],
user_id=user_id,
agent_id=agent_id,
source=source,
session_id=session_id,
content_type="text",
extra=extra,
memory_at=memory_at,
request_id=request_id,
)
# lite/pro → MemoryWriter;ultra → System2Writer(内部复用 MemoryWriter)
if self._mode == "ultra":
writer = await self._registry.get_system2_writer()
else:
writer = await self._registry.get_writer()
result: WriteResponse = await writer.write_extracted(write_req)
resp = {
"success": result.success,
"scene": "chat",
"memory_id": result.memory_id,
"request_id": request_id,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_code": result.error_code if result.error_code else None,
"error_message": result.error_message or None,
}
if result.extra.get("reconcile_ops"):
resp["ops_summary"] = result.extra["reconcile_ops"]
if result.extra.get("agent_stored_ids"):
resp["memory_ids"] = result.extra["agent_stored_ids"]
_attach_write_diagnostics(resp, result.extra)
resp["input_tokens"] = input_tokens
resp["reconcile_tokens"] = result.extra.get(
"reconcile_tokens", {"prompt": 0, "completion": 0, "total": 0}
)
if result.success:
logger.info(
f"[add_extracted] done: {len(result.extra.get('agent_stored_ids', []))} "
f"nodes from {len(content_list)} contents, elapsed={resp['elapsed_ms']:.0f}ms"
)
else:
logger.warning(f"[add_extracted] failed: {result.error_message}")
return resp
@staticmethod
def _raw_node_to_public(node) -> Dict[str, Any]:
def _timestamp(value):
return int(value.timestamp()) if isinstance(value, datetime) else None
return {
"raw_id": node.node_id,
"content": node.content,
"user_id": node.user_id,
"agent_id": node.agent_id,
"session_id": node.session_id,
"source": node.source,
"memory_at": _timestamp(node.memory_at),
"gmt_created": _timestamp(node.gmt_created),
"custom": node.custom or {},
}
@_ensure_internal_loop
async def _search_explicit_raw_layer(
self,
query: str,
*,
user_ids: List[str],
agent_ids: List[str],
session_ids: List[str],
include_sources: List[str],
exclude_sources: List[str],
limit: int,
min_score: float,
created_after: Optional[float],
request_id: str,
t0: float,
query_embedding: Optional[List[float]],
) -> Dict[str, Any]:
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
from .models.memory import MemoryLayer, MemoryStatus
embedding = query_embedding or await self._embed_service.embed(query)
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
hits = await self._vector_store.search(
query_embedding=embedding,
user_ids=user_ids,
agent_ids=agent_ids,
include_sources=include_sources,
exclude_sources=exclude_sources,
layers=[MemoryLayer.L1_RAW],
limit=fetch,
score_threshold=min_score,
status_filter=[MemoryStatus.ACTIVE],
only_latest=False,
created_after=created_after,
)
sessions = set(session_ids)
rows: List[Dict[str, Any]] = []
for hit in hits:
node = hit.get("node")
if node is None or node.layer != MemoryLayer.L1_RAW:
continue
if sessions and node.session_id not in sessions:
continue
row = self._raw_node_to_public(node)
rows.append(
{
"memory_id": row.pop("raw_id"),
**row,
"layer": MemoryLayer.L1_RAW.value,
"status": MemoryStatus.ACTIVE.value,
"score": round(float(hit.get("score", 0.0)), 4),
}
)
if len(rows) >= limit:
break
return {
"request_id": request_id,
"memories": {"schema": [], "proactive": [], "normal": rows},
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
}
@staticmethod
def _public_search_views(
result_memories: List[Dict[str, Any]],
*,
wanted_layers: Optional[set],
lite_mode: bool,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Build the public search items and the private dedup view together.
The dedup marker must come from the same source element as its public
item. Building the dedup view with a second ``zip(result_memories,
all_mems)`` is wrong as soon as one result is dropped by the layer
filter: every later kind shifts by one, and a canonical_state can enter
fuzzy dedup and be shadowed.
"""
from .models.memory import MemoryLayer
all_mems: List[Dict[str, Any]] = []
dedup_mems: List[Dict[str, Any]] = []
for mem in result_memories:
if wanted_layers is not None and mem.get("layer", "") not in wanted_layers:
continue
if lite_mode and mem.get("layer", "") != MemoryLayer.L1_RAW.value:
continue
score = mem.get("score", 0.0)
item = {
"memory_id": mem.get("memory_id", ""),
"content": mem.get("content", ""),
"score": round(score, 4),
"layer": mem.get("layer", ""),
# 多 user 检索时调用方需据此追溯归属(R1)
"user_id": mem.get("user_id", "") or "",
"agent_id": mem.get("agent_id", "") or "",
"source": mem.get("source", "default") or "default",
"retrieval_source": mem.get("retrieval_source", "") or "",
"owner": mem.get("owner"),
"speculate": mem.get("speculate"),
# 不透出 source_raw_memory_id:它指向 L1_RAW 节点,而 raw 对外
# 读路径一律不可见,该 id 到手也查不到东西(按 ID 取返回"不存在"),
# 留着只会让调用方以为能据此追溯原文。
# 节点上仍保留该字段,内部溯源与 clone 重映射不受影响。
"tags": mem.get("tags") or [],
"memory_at": mem.get("memory_at"),
"gmt_created": mem.get("gmt_created"),
# 调用方自定义 meta(透传负载)。由 reader 从 custom["meta"] 取出,
# 未实现该字段的 reader 下为空列表。
"meta": mem.get("meta") or [],
"_schema_channel": bool(mem.get("_schema_channel")),
}
if mem.get("schema_type"):
item["schema_type"] = mem["schema_type"]
if mem.get("workspace_id"):
item["workspace_id"] = mem["workspace_id"]
if mem.get("branch"):
item["branch"] = mem["branch"]
if mem.get("evolution_chain"):
item["evolution_chain"] = mem["evolution_chain"]
if mem.get("capsule_memories") is not None:
item["capsule_memories"] = mem["capsule_memories"]
if mem.get("episode_id"):
item["episode_id"] = mem["episode_id"]
if mem.get("member_ids") is not None:
item["member_ids"] = mem["member_ids"]
if mem.get("timeline") is not None:
item["timeline"] = mem["timeline"]
if mem.get("rerank_score") is not None:
item["rerank_score"] = mem["rerank_score"]
all_mems.append(item)
dedup_item = dict(item)
dedup_item["_memory_kind"] = mem.get("_memory_kind", "")
dedup_mems.append(dedup_item)
return all_mems, dedup_mems
def _spawn_search_dedup(
self,
all_mems: List[Dict[str, Any]],
*,
request_id: str,
user_id: str,
agent_id: str,
) -> None:
"""对 search 结果中的 L2_FACT + L4_IDENTITY 做去重(fire-and-forget)。
只取这两层(重复重灾区);带 evolution_chain 的项视为链头,其 chain node_ids
从 evolution_chain 取出供连带处理。整体后台执行,不阻塞 search 返回。
判重按 owner 分批(group_items_by_owner):search 默认不传 agent_ids,
结果里可能混着多个 agent(甚至多个 user)的节点 —— owner=user 的记忆属于
用户这一侧,跨 agent 合并判重;owner=agent 的按 agent 隔离。
命中的重复项置 SHADOW(逻辑删除、可恢复),不是硬删。
"""
if os.getenv("MEMORY_SEARCH_DEDUP_ENABLED", "true").strip().lower() in {
"0", "false", "no", "off",
}:
return
import asyncio
from .pipelines._retrieval.dedup import (
DedupItem, execute_dedup, group_items_by_owner,
)
_DEDUP_LAYERS = {"l2_fact", "l4_identity"}
targets = [
m for m in all_mems
if (m.get("layer") or "").lower() in _DEDUP_LAYERS
and m.get("memory_id")
and m.get("_memory_kind") != "canonical_state"
]
if len(targets) < 2:
return
vector_store = self._vector_store
cache = getattr(self, "_cache", None)
async def _run():
try:
import time as _time
node_ids = [m["memory_id"] for m in targets]
_t_emb = _time.perf_counter()
embs = await vector_store.get_embeddings(node_ids)
_get_emb_ms = round((_time.perf_counter() - _t_emb) * 1000, 2)
items: List[DedupItem] = []
for m in targets:
mid = m["memory_id"]
emb = embs.get(mid)
if not emb:
continue
chain = m.get("evolution_chain") or []
chain_ids = [c.get("node_id") for c in chain if c.get("node_id")] if chain else [mid]
items.append(DedupItem(
node_id=mid,
embedding=emb,
content=m.get("content", ""),
is_latest=True,
is_chain_head=bool(chain),
gmt_created=(float(m["gmt_created"]) if m.get("gmt_created") else None),
chain_node_ids=chain_ids or [mid],
# 分批维度:search 默认不传 agent_ids,_all_mems 里可能混着
# 多个 agent(甚至多个 user)的节点。owner=user 的跨 agent
# 合并判重,owner=agent 的按 agent 隔离。
owner=m.get("owner"),
user_id=m.get("user_id", "") or user_id,
agent_id=m.get("agent_id", "") or agent_id,
source=m.get("source", "default") or "default",
))
if len(items) >= 2:
for _batch in group_items_by_owner(
items,
default_user_id=user_id,
default_agent_id=agent_id,
):
await execute_dedup(
_batch["items"],
vector_store=vector_store,
cache=cache,
trigger="search",
request_id=request_id,
# 此前固定传 effective_*_ids[0],跨 user/跨 agent
# 检索时 DEDUP log 的归属是错的(记第一个,删的可能
# 是另一个的数据)。分批后每批归属确定。
user_id=_batch["user_id"],
agent_id=_batch["agent_id"],
delete_from_store=True,
extra_parsed={
"embedding_source": "vdb_get_embeddings",
"get_embeddings_ms": _get_emb_ms,
"embed_count": len(node_ids),
"embed_ok_count": len(items),
"owner_bucket": _batch["owner"],
"source_bucket": _batch["source"],
"batch_size": len(_batch["items"]),
},
)
except Exception as e:
logger.debug(f"[search] background dedup failed: {e}")
try:
# dedup 是非用户可感知的后台任务,且只碰 VDB(线程池,无 loop 亲和)。
# 显式派发到 write loop,避免占用 search loop 的调度权、让下一次 search 排队。
asyncio.run_coroutine_threadsafe(_run(), self._loop_thread._loop)
except RuntimeError:
# 无事件循环(极少见):同步跳过,不影响主流程
logger.debug("[search] no running loop for dedup; skipped")
@staticmethod
def _unify_productivity_item(
item: Dict[str, Any], *, kind: str, scene: str,
) -> Dict[str, Any]:
"""生产力经验/事实 → scene="all" 统一条目(保留原字段)。"""
if kind == "experience":
task = str(item.get("task") or "").strip()
solution = str(item.get("solution") or "").strip()
content = "\n".join(part for part in (task, solution) if part)
memory_id = item.get("experience_id")
created = _epoch_seconds(item.get("created_at"), naive_is_utc=True)
updated = _epoch_seconds(item.get("updated_at"), naive_is_utc=True)
else:
content = str(item.get("statement") or "")
memory_id = item.get("fact_id")
created = _epoch_seconds(item.get("created_at"), naive_is_utc=True)
# 事实没有修改时间列:写入后只会被 SUPERSEDE,不会原地改
updated = created
return {
**item,
"scene": scene,
"kind": kind,
"memory_id": memory_id,
"content": content,
"source": item.get("source") or "default",
"created_at": created,
"updated_at": updated if updated is not None else created,
}
@staticmethod
def _search_lane_items(lane: str, resp: Dict[str, Any]) -> List[Dict[str, Any]]:
"""某一路的 search 结果 → 按本路名次排好的统一条目列表。
chat:normal → schema → proactive(与 reader 的组装顺序一致)。
working/coding:事实与经验各按原顺序交替、事实在前。两层本就分开计数
(各自 ≤ limit),交替让融合后两层都有代表,谁也不压谁。
"""
out: List[Dict[str, Any]] = []
if lane == "chat":
memories = resp.get("memories") or {}
if isinstance(memories, dict):
ordered = (
list(memories.get("normal") or [])
+ list(memories.get("schema") or [])
+ list(memories.get("proactive") or [])
)
else:
ordered = list(memories)
for mem in ordered:
created = _epoch_seconds(mem.get("gmt_created"))
out.append({
**mem,
"scene": "chat",
"kind": "chat",
"lane_score": mem.get("score"),
"source": mem.get("source") or "default",
"created_at": created,
"updated_at": _epoch_seconds(mem.get("gmt_modified")) or created,
})
return out
facts = [
HyMemoryClient._unify_productivity_item(f, kind="fact", scene=lane)
for f in (resp.get("facts") or [])
]
exps = [
HyMemoryClient._unify_productivity_item(e, kind="experience", scene=lane)
for e in (resp.get("experiences") or [])
]
for index in range(max(len(facts), len(exps))):
if index < len(facts):
out.append(facts[index])
if index < len(exps):
out.append(exps[index])
for item in out:
item["lane_score"] = item.get("score")
return out
@staticmethod
def _rrf_fuse_lanes(
lane_items: Dict[str, List[Dict[str, Any]]],
*,
limit: int,
k: int = _SCENE_RRF_K,
) -> List[Dict[str, Any]]:
"""按各路自己的名次做 RRF:score = 1 / (k + rank),rank 从 1 起。
条目来自不同 namespace,不跨路合并同 id;同分按 _SCENE_LANES 顺序、再按
本路名次排,结果稳定。截断到 limit。
"""
fused: List[Tuple[float, int, int, Dict[str, Any]]] = []
for lane_order, lane in enumerate(_SCENE_LANES):
for rank, item in enumerate(lane_items.get(lane) or [], start=1):
score = 1.0 / (k + rank)
fused.append((score, lane_order, rank, {
**item,
"score": round(score, 6),
"lane_rank": rank,
}))
fused.sort(key=lambda entry: (-entry[0], entry[1], entry[2]))
return [entry[3] for entry in fused[:max(0, int(limit))]]
async def _search_all_scenes(
self,
query: Optional[str],
*,
queries: Optional[List[str]],
search_mode: str,
user_ids: List[str],
agent_ids: List[str],
session_ids: List[str],
include_sources: List[str],
exclude_sources: List[str],
limit: int,
min_score: float,
working_fact_min_score: Optional[float],
working_experience_min_score: Optional[float],
schema_min_score: float,
schema_limit: int,
intention_limit: int,
created_after: Optional[float],
reader: str,
workspace_id: Optional[str],
branch: Optional[str],
request_id: str,
layers: Optional[List[Any]],
query_embedding: Optional[List[float]],
rerank: bool,
target_query: str,
lite_mode: bool,
t0: float,
) -> Dict[str, Any]:
"""search(scene="all"):三路并发召回 + RRF 融合。字段见 search() docstring。
scene 是显式路由,不做 LLM 判类/改写:三路都用 queries[-1](或 query)。
query 向量只算一次、三路共用。任一路异常只记进 errors,不让整次失败。
"""
errors: Dict[str, str] = {}
coding_cfg = getattr(getattr(self, "_config", None), "coding", None)
coding_enabled = bool(coding_cfg is not None and coding_cfg.enable)
embedding = query_embedding
if not embedding:
try:
embedding = await self._embed_service.embed(target_query)
except Exception as e:
# 每一路自己再 embed 一次(也可能各自失败,届时分别记错)
logger.warning(f"[search-all] shared embed failed: {e}")
embedding = None
async def _chat_lane() -> Dict[str, Any]:
return await self._async_search_impl(
query, queries=queries, scene="chat", search_mode=search_mode,
user_ids=user_ids, agent_ids=agent_ids, session_ids=session_ids,
include_sources=include_sources, exclude_sources=exclude_sources,
limit=limit, min_score=min_score,
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, request_id=request_id, layers=layers,
query_embedding=embedding, rerank=rerank,
)
async def _productivity_lane(variant: str) -> Optional[Dict[str, Any]]:
# 没写过的场景直接跳过:不为一次检索建空库
store = await self._coding_store_for_exact_get(variant)
if store is None:
return None
return await self._search_coding(
target_query,
user_ids=user_ids, agent_ids=agent_ids, session_ids=session_ids,
workspace_id=workspace_id, branch=branch, limit=limit,
working_fact_min_score=working_fact_min_score,
working_experience_min_score=working_experience_min_score,
request_id=request_id, t0=t0, variant=variant,
include_sources=include_sources, exclude_sources=exclude_sources,
rerank=rerank, query_embedding=embedding,
)
lanes_to_run: Dict[str, Any] = {"chat": _chat_lane()}
for variant in ("working", "coding"):
if lite_mode:
errors[variant] = "disabled: lite mode is chat raw-only"
elif not coding_enabled:
errors[variant] = "disabled: productivity link disabled in config"
else:
lanes_to_run[variant] = _productivity_lane(variant)
names = list(lanes_to_run)
results = await asyncio.gather(
*lanes_to_run.values(), return_exceptions=True,
)
lane_items: Dict[str, List[Dict[str, Any]]] = {}
for name, result in zip(names, results):
if isinstance(result, BaseException):
if not isinstance(result, Exception):
raise result
logger.warning(f"[search-all] lane {name} failed: {result}")
errors[name] = f"{type(result).__name__}: {result}"
continue
if result is None:
continue
lane_items[name] = self._search_lane_items(name, result)
memories = self._rrf_fuse_lanes(lane_items, limit=limit)
lanes = {lane: len(lane_items.get(lane) or []) for lane in _SCENE_LANES}
elapsed = round((time.perf_counter() - t0) * 1000, 2)
logger.info(
f"[search-all] lanes={lanes} returned={len(memories)} "
f"errors={sorted(errors)} rerank={rerank} elapsed_ms={elapsed}"
)
out: Dict[str, Any] = {
"request_id": request_id,
"scene": "all",
"memories": memories,
"lanes": lanes,
"errors": errors,
"elapsed_ms": elapsed,
}
return out
@_ensure_search_loop
async def async_search(
self,
query: Optional[str] = None,
*,
queries: Optional[List[str]] = None,
scene: Optional[str] = None,
search_mode: str = "default",
user_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
limit: int = 10,
min_score: float = 0.4,
working_fact_min_score: Optional[float] = None,
working_experience_min_score: Optional[float] = None,
schema_min_score: float = 0.4,
schema_limit: int = 10,
intention_limit: int = 0,
created_after: Optional[float] = None,
reader: str = "",
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
) -> Dict[str, Any]:
"""搜索记忆(异步)。详见 search() docstring。
embed_override: 本次搜索 query embedding 的端点覆盖(不改 model/dims,只换接入点)。
dict,可含 base_url / api_key / extra_headers / timeout;None = 用 client 配置。
须在 @_ensure_search_loop 委托进 search loop **之后**再 set contextvar——
contextvar 不跨 run_coroutine_threadsafe 边界,故在此(已在 search loop 上)包裹。
"""
if embed_override:
with embed_endpoint_override(
base_url=embed_override.get("base_url"),
api_key=embed_override.get("api_key"),
extra_headers=embed_override.get("extra_headers"),
timeout=embed_override.get("timeout"),
):
return await self._async_search_impl(
query, queries=queries, scene=scene, search_mode=search_mode,
user_ids=user_ids,
agent_ids=agent_ids, session_ids=session_ids, limit=limit,
include_sources=include_sources, exclude_sources=exclude_sources,
min_score=min_score,
working_fact_min_score=working_fact_min_score,
working_experience_min_score=working_experience_min_score,
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
query_embedding=query_embedding,
rerank=rerank,
)
return await self._async_search_impl(
query, queries=queries, scene=scene, search_mode=search_mode,
user_ids=user_ids,
agent_ids=agent_ids, session_ids=session_ids, limit=limit,
include_sources=include_sources, exclude_sources=exclude_sources,
min_score=min_score,
working_fact_min_score=working_fact_min_score,
working_experience_min_score=working_experience_min_score,
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
query_embedding=query_embedding,
rerank=rerank,
)
async def _async_search_impl(
self,
query: Optional[str] = None,
*,
queries: Optional[List[str]] = None,
scene: Optional[str] = None,
search_mode: str = "default",
user_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
limit: int = 10,
min_score: float = 0.4,
working_fact_min_score: Optional[float] = None,
working_experience_min_score: Optional[float] = None,
schema_min_score: float = 0.4,
schema_limit: int = 10,
intention_limit: int = 0,
created_after: Optional[float] = None,
reader: str = "",
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
) -> Dict[str, Any]:
"""搜索记忆(异步)。详见 search() docstring。
layers(默认空):只召回指定层级,接受字符串或 MemoryLayer。
过滤在召回阶段生效(而非召回后再筛),避免无关层级挤占 limit 名额。
"""
# ── 入参归一化 ──
if queries is not None and query is not None:
raise ValueError("query and queries are mutually exclusive; choose one")
if queries is None and query is None:
raise ValueError("either query or queries is required")
queries_supplied = queries is not None
if queries is None:
queries_list: List[str] = [query]
else:
if not isinstance(queries, list) or not queries:
raise ValueError("queries must be a non-empty list[str]")
queries_list = [str(q) for q in queries]
target_query = queries_list[-1]
search_mode_norm = str(search_mode or "default").strip().lower()
if search_mode_norm not in ("default", "agentic"):
raise ValueError(
f"search_mode must be 'default' or 'agentic', got {search_mode!r}"
)
# scene 校验 + 归一化
# 对外统一用 "chat" / "working" / "coding"(与 add() 一致);内部检索链路仍用
# "normal" / "productivity",这里在入参边界做一次映射。
# "working" → "productivity"(走 coding 召回,通用生产力变体)
# "coding" → "productivity"(走 coding 召回,软件工程变体:额外挂
# 版本演进链块,见 _scene_variant / _decorate_with_chain)
# "chat" → "normal"(走 chat 召回)
# "productivity" / "normal" 作为旧别名保留,向后兼容。
scene_norm: Optional[str] = None
scene_all = scene is not None and str(scene).strip().lower() == "all"
if scene is not None and not scene_all:
sn = str(scene).strip().lower()
_scene_map = {
"working": "productivity",
"coding": "productivity",
"chat": "normal",
"productivity": "productivity", # 向后兼容
"normal": "normal", # 向后兼容
}
if sn not in _scene_map:
raise ValueError(
f"scene must be 'chat', 'working', 'coding' or 'all', got {scene!r}"
)
scene_norm = _scene_map[sn]
# 生成 / 复用 request_id 并注入日志上下文(App 可传入以对齐 SEARCH_API_ACCEPTED)
request_id = (request_id or "").strip() or str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
# 运行时停顿归因基线:与 t0 同点采样,终值在 log_scene_judge 前取,差值落进
# READ_SCENE_JUDGE.parsed。用于解释"纯同步代码却耗几百 ms"——区分
# cgroup CPU throttling(throttle_ms≈elapsed_ms)/ GC 长停顿(gc_delta>=1)/
# GIL 争抢(两者都≈0 但仍慢)。二者均为同步求值,与 elapsed_ms 同等可信。
_stall_gc0 = _gc_collection_count()
_stall_thr0 = _read_throttled_usec()
effective_user_ids = user_ids if user_ids is not None else []
effective_agent_ids = agent_ids if agent_ids is not None else []
effective_session_ids = session_ids if session_ids is not None else []
lite_mode = getattr(self, "_mode", "pro") == "lite"
from .utils.source_filter import normalize_source_filters
effective_include_sources, effective_exclude_sources = normalize_source_filters(
include_sources, exclude_sources,
)
# 校验: 必须传 user_ids
if not effective_user_ids:
raise ValueError("user_ids is required and cannot be empty")
# 校验: 多 agent_ids + 多 session_ids 非法
if len(effective_agent_ids) > 1 and len(effective_session_ids) > 1:
raise ValueError(
"Cannot specify multiple agent_ids and multiple session_ids simultaneously. "
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
)
if scene_all:
return await self._search_all_scenes(
query, queries=queries, search_mode=search_mode,
user_ids=effective_user_ids,
agent_ids=effective_agent_ids,
session_ids=effective_session_ids,
include_sources=effective_include_sources,
exclude_sources=effective_exclude_sources,
limit=limit, min_score=min_score,
working_fact_min_score=working_fact_min_score,
working_experience_min_score=working_experience_min_score,
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
query_embedding=query_embedding, rerank=rerank,
target_query=target_query, lite_mode=lite_mode, t0=t0,
)
# ────────────────────────────────────────────────────────────
# Search mode + scene 路由
#
# scene 显式指定 → 直接路由,零 LLM
# default + query="..." → 固定 normal,零 LLM
# default + queries=[...] → 保持原多-query judge/rewrite 逻辑
# agentic + query="..." / queries=[...] → 三分类 + rewrite + 时间
# default 多-query 输入超长 → 跳 rewrite;需要时仅做分类
#
# queries= 本身就是用户对上下文 judge/rewrite 的显式选择;agentic 则进一步
# 让单 query 也走同样能力。query_rewrite_max_chars 作为共同长度保护。
# ────────────────────────────────────────────────────────────
coding_enabled = (
getattr(self._config, "coding", None) is not None
and self._config.coding.enable
)
agentic_enabled = search_mode_norm == "agentic"
rewrite_enabled = bool(
getattr(self._config.llm, "enable_query_rewrite", False)
)
_max_chars = int(getattr(self._config.llm, "query_rewrite_max_chars", 1000) or 1000)
_combined_len = sum(len(q) for q in queries_list)
_too_long = _combined_len > _max_chars
effective_target_query = target_query # 默认用 queries[-1] 或 query
rewrite_created_after: Optional[float] = None # rewrite 抽出的时间下界
decided_scene: str
decided_variant: str = HyMemoryClient._scene_variant(scene)
rewrite_used: bool = False
scene_mode: str = "default" # explicit / llm / default
scene_is_coding: bool = False
_t_scene = t0
# scene judge 真实的 LLM 净耗时。只有走 LLM 分支(scene_mode="llm")才有值;
# mode=default/explicit 是纯同步分支,保持 None → 不落 parsed.llm_ms。
# 与「本环节整体耗时」(elapsed_ms → parsed.step_ms)严格区分。
_scene_llm_ms: Optional[float] = None
# 当前系统时间(供 rewrite 换算相对时间)
from datetime import datetime as _dt_now
_current_time_iso = _dt_now.now().isoformat(timespec="seconds")
def _ensure_llm():
if self._coding_llm_provider is None:
from .agent.llm_provider import LLMProvider
self._coding_llm_provider = LLMProvider(self._config)
return self._coding_llm_provider
if lite_mode:
# lite 是严格的 raw-only 向量检索模式。scene / search_mode 不得把它
# 路由到 working/coding,也不应触发任何 LLM judge/rewrite。
decided_scene = "normal"
scene_mode = "lite"
scene_is_coding = False
decided_variant = "working"
logger.info(
"[search] mode=lite forces raw-only normal search "
"(ignore scene/search_mode/layers; no LLM)"
)
elif scene_norm is not None:
# 1) 显式指定 → 不调 LLM
decided_scene = scene_norm
scene_mode = "explicit"
scene_is_coding = (decided_scene == "productivity")
decided_variant = HyMemoryClient._scene_variant(scene)
logger.info(
f"[search] scene explicit={decided_scene} (no LLM judge); "
f"target({len(effective_target_query or '')} chars)"
)
elif not agentic_enabled and not queries_supplied:
# 默认 light search 仅指 query="...":固定 normal,绝不调用 LLM。
decided_scene = "normal"
logger.info(
f"[search] search_mode=default single-query scene=normal (no LLM); "
f"target({len(target_query or '')} chars)"
)
elif agentic_enabled:
# agentic:单/多 query 都强制一次 LLM 完成三分类 + rewrite + 时间解析。
# judge 内部已有有界 query 视图,因此原始 queries 再长也不降级成 classify-only。
want_classify = True
_t_llm = time.perf_counter()
try:
from .coding.judge import analyze_queries
ar = await analyze_queries(
queries_list, _ensure_llm(),
want_classify=want_classify, want_rewrite=True,
current_time=_current_time_iso,
)
except Exception as e:
logger.warning(f"[search] analyze_queries failed, fallback normal: {e}")
ar = {"is_coding": False, "rewrite_query": target_query,
"created_after": None, "ok": False}
# 失败也计时:失败往往正是超时,耗时本身是关键信息
_scene_llm_ms = (time.perf_counter() - _t_llm) * 1000
scene_mode = "llm"
if ar.get("ok"):
judged_variant = str(ar.get("scene") or "chat") if want_classify else "chat"
scene_is_coding = judged_variant in ("working", "coding")
if scene_is_coding:
decided_variant = judged_variant
decided_scene = "productivity" if scene_is_coding else "normal"
new_q = ar.get("rewrite_query") or target_query
if new_q and new_q != target_query:
effective_target_query = new_q
rewrite_used = True
rewrite_created_after = ar.get("created_after")
logger.info(
f"[search] agentic scene={decided_scene} via rewrite-LLM "
f"(classify={want_classify}); rewrite_used={rewrite_used} "
f"created_after={rewrite_created_after}"
)
logger.debug(
f"[search] agentic rewrite: orig={target_query!r} "
f"effective={effective_target_query!r}"
)
else:
decided_scene = "normal"
logger.info(
f"[search] agentic rewrite-LLM fallback=normal; "
f"target({len(target_query or '')} chars)"
)
elif rewrite_enabled and not _too_long:
# default + queries= 的原逻辑:配置开启时一次 LLM 做 rewrite/time,
# working memory 开启时同时三分类。
want_classify = coding_enabled
_t_llm = time.perf_counter()
try:
from .coding.judge import analyze_queries
ar = await analyze_queries(
queries_list, _ensure_llm(),
want_classify=want_classify, want_rewrite=True,
current_time=_current_time_iso,
)
except Exception as e:
logger.warning(f"[search] multi-query analyze failed, fallback normal: {e}")
ar = {"scene": "chat", "is_coding": False,
"rewrite_query": target_query, "created_after": None, "ok": False}
_scene_llm_ms = (time.perf_counter() - _t_llm) * 1000
scene_mode = "llm"
if ar.get("ok"):
judged_variant = str(ar.get("scene") or "chat") if want_classify else "chat"
scene_is_coding = judged_variant in ("working", "coding")
if scene_is_coding:
decided_variant = judged_variant
decided_scene = "productivity" if scene_is_coding else "normal"
new_q = ar.get("rewrite_query") or target_query
if new_q and new_q != target_query:
effective_target_query = new_q
rewrite_used = True
rewrite_created_after = ar.get("created_after")
else:
decided_scene = "normal"
elif coding_enabled and not _too_long:
# default + queries= 的原逻辑:working memory 开启时判类 + 指代改写。
_t_llm = time.perf_counter()
try:
from .coding.judge import classify_and_rewrite_queries
jr = await classify_and_rewrite_queries(queries_list, _ensure_llm())
except Exception as e:
logger.warning(f"[search] multi-query classify/rewrite failed: {e}")
jr = {"scene": "chat", "is_coding": False,
"rewrite_query": target_query, "ok": False}
_scene_llm_ms = (time.perf_counter() - _t_llm) * 1000
scene_mode = "llm"
if jr.get("ok"):
judged_variant = str(jr.get("scene") or "chat")
scene_is_coding = judged_variant in ("working", "coding")
if scene_is_coding:
decided_variant = judged_variant
decided_scene = "productivity" if scene_is_coding else "normal"
new_q = jr.get("rewrite_query") or target_query
if new_q and new_q != target_query:
effective_target_query = new_q
rewrite_used = True
else:
decided_scene = "normal"
elif coding_enabled:
# default + 超长 queries:保留旧行为,只分类、不改写。
_t_llm = time.perf_counter()
try:
from .coding.judge import classify_queries_scene
judged_variant = await classify_queries_scene(
queries_list, _ensure_llm()
)
scene_is_coding = judged_variant in ("working", "coding")
if scene_is_coding:
decided_variant = judged_variant
except Exception as e:
logger.warning(f"[search] multi-query classify failed: {e}")
scene_is_coding = False
_scene_llm_ms = (time.perf_counter() - _t_llm) * 1000
scene_mode = "llm"
decided_scene = "productivity" if scene_is_coding else "normal"
else:
# default + queries=,但两个相关能力都关闭。
decided_scene = "normal"
logger.info(
f"[search] default multi-query features disabled; no LLM; "
f"target({len(target_query or '')} chars)"
)
# ────────────────────────────────────────────────────────────
# 场景判定落 pipeline log(search 的第一步,在 reader 之前)
# ────────────────────────────────────────────────────────────
if getattr(self, "_cache", None) is not None:
# 停顿归因终值:必须在构造 ReadTraceLogger / 调 log_scene_judge 之前取,
# 与 elapsed_ms 的 perf_counter 口径圈定同一段代码(t0 → 此处)。
_stall_gc_delta = _gc_collection_count() - _stall_gc0
_stall_throttle_ms = (_read_throttled_usec() - _stall_thr0) / 1000.0
try:
from .pipelines._retrieval.trace import ReadTraceLogger
_scene_trace = ReadTraceLogger(
cache=self._cache,
request_id=request_id,
user_id=(effective_user_ids[0] if effective_user_ids else ""),
agent_id=(effective_agent_ids[0] if effective_agent_ids else "default_agent"),
reader_version="client",
session_id=(effective_session_ids[0] if len(effective_session_ids) == 1 else ""),
)
await _scene_trace.log_scene_judge(
query=target_query or "",
scene=decided_scene,
mode=scene_mode,
is_coding=scene_is_coding,
rewrite_query=(effective_target_query if rewrite_used else None),
rewrite_used=rewrite_used,
created_after=(
datetime.fromtimestamp(rewrite_created_after).strftime("%Y-%m-%d %H:%M:%S")
if rewrite_created_after is not None else None
),
elapsed_ms=(time.perf_counter() - _t_scene) * 1000 if _t_scene else 0.0,
llm_ms=_scene_llm_ms,
gc_delta=_stall_gc_delta,
throttle_ms=_stall_throttle_ms,
)
except Exception as _e:
logger.debug(f"[search] scene judge trace failed: {_e}")
# ────────────────────────────────────────────────────────────
# 按决定的 scene 分流
# ────────────────────────────────────────────────────────────
if decided_scene == "productivity":
if not coding_enabled:
logger.warning(
"[search] scene=productivity requested but productivity link disabled; "
"falling back to normal"
)
decided_scene = "normal"
else:
logger.info(
f"[search] routing to PRODUCTIVITY path variant={decided_variant}: "
f"target({len(effective_target_query or '')} chars) "
f"workspace_id={workspace_id!r} branch={branch!r}"
)
return await self._search_coding(
effective_target_query,
user_ids=effective_user_ids,
agent_ids=effective_agent_ids,
session_ids=effective_session_ids,
workspace_id=workspace_id,
branch=branch,
limit=limit,
working_fact_min_score=working_fact_min_score,
working_experience_min_score=working_experience_min_score,
request_id=request_id,
t0=t0,
# 走类而不是 self:_scene_variant 是 staticmethod,而 async_search
# 会被测试绑到 SimpleNamespace 上单独调用(见
# tests/coding/test_search_scene_routing.py),self 上没有这个属性。
variant=decided_variant,
include_sources=effective_include_sources,
exclude_sources=effective_exclude_sources,
rerank=rerank,
)
# ────────────────────────────────────────────────────────────
# Chat 召回链路(原有逻辑)
# ────────────────────────────────────────────────────────────
logger.info(
f"[search] query({len(effective_target_query or '')} chars) "
f"user_ids={effective_user_ids} "
f"agent_ids={effective_agent_ids} session_ids={effective_session_ids} limit={limit}"
)
logger.debug(f"[search] query={effective_target_query!r}")
# rewrite 抽出的时间下界与用户显式 created_after 取更晚者(更严格)
effective_created_after = created_after
if rewrite_created_after is not None:
effective_created_after = (
rewrite_created_after if effective_created_after is None
else max(effective_created_after, rewrite_created_after)
)
# lite 是纯向量存取:写入只产生 ACTIVE 的 L1_RAW,不会再抽取 L2/L4。
# 这是 mode 级硬约束,不是默认值:即使调用方显式传了其他 layers,也必须
# 忽略并只查 L1_RAW。pro / ultra 仍沿用原有层级语义。
from .models.memory import MemoryLayer
effective_search_layers = [MemoryLayer.L1_RAW] if lite_mode else layers
effective_schema_limit = 0 if lite_mode else schema_limit
effective_intention_limit = 0 if lite_mode else intention_limit
normalized_search_layers = self._normalize_layers(effective_search_layers)
if normalized_search_layers == [MemoryLayer.L1_RAW]:
return await self._search_explicit_raw_layer(
effective_target_query,
user_ids=effective_user_ids,
agent_ids=effective_agent_ids,
session_ids=effective_session_ids,
include_sources=effective_include_sources,
exclude_sources=effective_exclude_sources,
limit=limit,
min_score=min_score,
created_after=effective_created_after,
request_id=request_id,
t0=t0,
query_embedding=query_embedding,
)
read_req = ReadRequest(
query=effective_target_query,
user_ids=effective_user_ids,
agent_ids=effective_agent_ids,
session_ids=effective_session_ids,
include_sources=effective_include_sources,
exclude_sources=effective_exclude_sources,
limit=limit,
min_score=min_score,
schema_min_score=schema_min_score,
schema_limit=effective_schema_limit,
intention_limit=effective_intention_limit,
request_id=request_id,
created_after=effective_created_after,
# ReadRequest.layers 是 List[str];reader 侧再转回枚举。
# 此处仍走 _normalize_layers,以便非法值在入口就报错、并统一旧编号别名。
layers=(
[
layer.value
for layer in normalized_search_layers
]
if effective_search_layers else None
),
query_embedding=query_embedding,
rerank=bool(rerank),
)
reader_impl = await self._registry.get_reader(reader_name=reader)
logger.debug(f"[search] using reader: {reader_impl.VERSION}")
result: ReadResponse = await reader_impl.read(read_req)
if not result.success:
logger.warning(f"[search] failed: {result.error_message}")
return {
"request_id": request_id,
"memories": {"schema": [], "proactive": [], "normal": []},
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
}
# layers 兜底过滤:定向召回由各 reader 实现,但 reader 的支持程度不一
# (profile / graph 路常按固定层级召回,不吃 request.layers),只靠 reader
# 会漏出未被请求的层级。契约是「只返回命中层级」,故在此再筛一次。
_wanted_layers = (
{
layer.value
for layer in normalized_search_layers
}
if effective_search_layers else None
)
_all_mems, _dedup_mems = self._public_search_views(
result.memories,
wanted_layers=_wanted_layers,
lite_mode=lite_mode,
)
# search 链路去重:仅对 L2_FACT + L4_IDENTITY(重复重灾区)判重删库。
# L0 及其他层不参与。fire-and-forget,不阻塞本次返回(清理面向后续查询)。
try:
self._spawn_search_dedup(
_dedup_mems,
request_id=request_id,
user_id=(effective_user_ids[0] if effective_user_ids else ""),
agent_id=(effective_agent_ids[0] if effective_agent_ids else ""),
)
except Exception as e:
logger.debug(f"[search] spawn dedup failed (non-fatal): {e}")
# Memory Strength:搜索后 best-effort 回写 access_count+1 / last_accessed_at=now
# (fire-and-forget,不阻塞响应;失败静默)。用 reader 透出的 access_count 计算 +1,
# 避免再读一次。受总开关 strength_enabled 约束(默认关闭),并可用
# MEMORY_RECALL_ACCESS_TRACKING=false 单独关闭回写。
_recall_cfg = self._config.recall
if (getattr(_recall_cfg, "strength_enabled", False)
and getattr(_recall_cfg, "access_tracking_enabled", True)):
_bump_items = [
(mem.get("memory_id", ""), mem.get("access_count", 0))
for mem in result.memories
if mem.get("memory_id")
]
if _bump_items:
from .pipelines._retrieval.strength import bump_access as _bump_access
async def _do_bump():
try:
await _bump_access(self._vector_store, _bump_items)
except Exception as _be:
logger.debug(f"[search] access bump failed: {_be}")
try:
# access-bump 只碰 VDB,且不是关键路径 → 派发到 write loop,
# 不占用 search loop 调度权。
asyncio.run_coroutine_threadsafe(_do_bump(), self._loop_thread._loop)
except Exception as _be:
logger.debug(f"[search] access bump schedule failed: {_be}")
# 按 channel 分组:schema 只包含 L6;L0 由 get_user_profile() 单独返回。
# proactive = intention;其余(含 L2/L3/L4)归 normal。
_SCHEMA_LAYERS = {"l6_schema"}
_PROACTIVE_LAYERS = {"l7_intention"}
memories = {"schema": [], "proactive": [], "normal": []}
total = 0
for mem in _all_mems:
channel = _search_memory_channel(
mem,
schema_layers=_SCHEMA_LAYERS,
proactive_layers=_PROACTIVE_LAYERS,
schema_min_score=schema_min_score,
)
memories[channel].append(mem)
total += 1
logger.info(
f"[search] done: {total} results "
f"(schema={len(memories['schema'])} proactive={len(memories['proactive'])} "
f"normal={len(memories['normal'])}), elapsed={result.elapsed_ms:.0f}ms"
)
for ch, mems in memories.items():
for i, mem in enumerate(mems):
logger.debug(
f"[search] {ch}#{i} score={mem['score']:.4f} "
f"[{mem['layer']}] {mem['content']}"
)
# 记录搜索历史(可选,非阻塞)
if self._history_store is not None and self._config.history.record_searches:
try:
hist_user = effective_user_ids[0] if effective_user_ids else "default"
hist_agent = effective_agent_ids[0] if effective_agent_ids else "default"
isolation_key = self._build_isolation_key(hist_user, hist_agent)
await self._history_store.record_search(
query=effective_target_query,
isolation_key=isolation_key,
results_count=total,
)
except Exception as e:
logger.warning(f"[search] history record failed: {e}")
output = {
"request_id": request_id,
"memories": memories,
"rewrite": {
"used": rewrite_used,
"original_query": target_query,
"effective_query": effective_target_query,
"created_after": rewrite_created_after,
"scene": decided_scene,
},
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
}
for key in (
"episode_capsule",
"episode_member_expansion",
"retrieval_clauses",
"local_rerank",
"hierarchy_bundle",
"multi_seed_bundle",
"supplemental_propositions",
"dynamic_entity_tag_embedding",
"tag_index_nominee",
):
if key in result.extra:
output[key] = result.extra[key]
return output
# ================================================================
# CRUD API(异步)— get / update / delete / delete_all
# ================================================================
async def _coding_store_for_exact_get(self, variant: str):
"""Return an existing namespace store without creating an empty DB."""
stores = getattr(self, "_coding_stores", None) or {}
store = stores.get(variant)
if store is not None:
return store
from .coding.store import CodingMemoryStore
config = getattr(self, "_config", None)
coding_config = getattr(config, "coding", None)
if config is None or coding_config is None:
return None
if not CodingMemoryStore.database_exists(
config,
db_path=coding_config.db_path,
variant=variant,
):
return None
await self._get_coding_writer(variant=variant)
return (getattr(self, "_coding_stores", None) or {}).get(variant)
@staticmethod
def _format_productivity_get(
item: Any,
*,
kind: str,
variant: str,
request_id: str,
elapsed_ms: float,
) -> Dict[str, Any]:
payload = item.to_dict()
item_id = (
payload.get("experience_id")
if kind == "experience"
else payload.get("fact_id")
)
return {
"memory_id": item_id,
"kind": kind,
"variant": variant,
"scene": "productivity",
"content": (
payload.get("solution", "")
if kind == "experience"
else payload.get("statement", "")
),
"layer": kind,
**payload,
"request_id": request_id,
"elapsed_ms": elapsed_ms,
}
@staticmethod
def _validate_memory_namespace(
kind: Optional[str], variant: Optional[str],
) -> None:
if kind not in (None, "chat", "experience", "fact"):
raise ValueError(
"kind must be one of: chat, experience, fact"
)
if variant not in (None, "working", "coding"):
raise ValueError("variant must be one of: working, coding")
if kind == "chat" and variant is not None:
raise ValueError("variant is only valid for experience/fact lookups")
async def _resolve_memory_namespace(
self,
memory_id: str,
*,
kind: Optional[str],
variant: Optional[str],
chat_lookups: Dict[str, Any],
) -> Tuple[List[Tuple[str, Any]], Dict[str, Any], Dict[str, Any]]:
"""按 kind/variant 在 chat 与 working/coding 的经验/事实里精确查 ID。
get / update / delete 共用这一套解析:
- kind/variant 都不给:查 chat + 两个变体的经验和事实
- variant 给了:只查该变体(不查 chat)
- kind 给了:只查该类
没写过的变体不建空库(_coding_store_for_exact_get)。命中多于一个
namespace 时 fail closed,抛 AmbiguousMemoryIdError。
chat_lookups:{"chat:<name>": 零参协程函数},按顺序取第一个非 None 作为
chat 命中(get 传 vdb + graph,update 只传 vdb)。
Returns:
(matches, by_name, stores) —— matches 是 [(namespace, item)],
namespace 为 "chat" 或 "<variant>:<kind>";by_name 是每次查询的原始
结果;stores 是参与查询的 {variant: CodingMemoryStore}。
"""
query_chat = kind in (None, "chat") and variant is None
coding_kinds = (
("experience", "fact") if kind is None else (kind,)
)
coding_variants = (variant,) if variant is not None else ("working", "coding")
stores: Dict[str, Any] = {}
if kind != "chat":
for candidate_variant in coding_variants:
store = await self._coding_store_for_exact_get(candidate_variant)
if store is not None:
stores[candidate_variant] = store
tasks = []
task_names: List[str] = []
if query_chat:
for name, lookup in chat_lookups.items():
tasks.append(lookup())
task_names.append(name)
for candidate_variant, store in stores.items():
if "experience" in coding_kinds:
tasks.append(store.get_by_id(memory_id))
task_names.append(f"{candidate_variant}:experience")
if "fact" in coding_kinds:
tasks.append(store.get_fact(memory_id))
task_names.append(f"{candidate_variant}:fact")
values = await asyncio.gather(*tasks) if tasks else []
by_name = dict(zip(task_names, values))
matches: List[Tuple[str, Any]] = []
chat_node = next(
(by_name[name] for name in chat_lookups
if by_name.get(name) is not None),
None,
)
if chat_node is not None:
matches.append(("chat", chat_node))
for name, value in by_name.items():
if not name.startswith("chat:") and value is not None:
matches.append((name, value))
if len(matches) > 1:
raise AmbiguousMemoryIdError(
memory_id, [namespace for namespace, _ in matches]
)
return matches, by_name, stores
@_ensure_internal_loop
async def async_get(
self,
memory_id: str,
*,
kind: Optional[MemoryKind] = None,
variant: Optional[MemoryVariant] = None,
) -> Optional[Dict[str, Any]]:
"""
获取单条记忆(异步)。
默认查询 chat、working/coding experience 和 working/coding fact。
若同一 ID 命中多个 namespace,则 fail closed;调用方必须用 kind/variant
显式消歧。
Args:
memory_id: 记忆 ID
kind: 可选 namespace("chat" / "experience" / "fact")。
variant: 可选生产力变体("working" / "coding")。
Returns:
统一 memory dict,或 None。
"""
self._validate_memory_namespace(kind, variant)
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
logger.info(
"[get] memory_id=%s kind=%s variant=%s",
memory_id, kind, variant,
)
async def _get_vdb():
return await self._vector_store.get_by_id(memory_id)
async def _get_graph():
if self._graph_store is None:
return None
try:
return await self._graph_store.get_node(memory_id)
except Exception as e:
logger.debug(f"[get] graph_store query failed: {e}")
return None
matches, by_name, _stores = await self._resolve_memory_namespace(
memory_id, kind=kind, variant=variant,
chat_lookups={"chat:vdb": _get_vdb, "chat:graph": _get_graph},
)
vdb_node = by_name.get("chat:vdb")
graph_node = by_name.get("chat:graph")
elapsed = round((time.perf_counter() - t0) * 1000, 2)
_hit_namespace, _hit_node = matches[0] if matches else ("", None)
_source = (
"vdb" if vdb_node is not None
else ("graph" if graph_node is not None else _hit_namespace)
)
_ident = self._backend_identity()
_parsed = {
"memory_id": memory_id,
**_ident,
"hit": _hit_node is not None,
"source": _source,
"namespace": _hit_namespace,
}
if _hit_node is not None:
try:
_parsed["layer"] = _hit_node.layer.value
except Exception:
pass
await self._maybe_store_op_log(
step="GET",
request_id=request_id,
user_id=(_hit_node.user_id if _hit_node else "") or "",
agent_id=(getattr(_hit_node, "agent_id", "") if _hit_node else "") or "",
parsed=_parsed,
memory_ids=[memory_id],
elapsed_ms=elapsed,
)
if not matches:
logger.info(f"[get] not found: memory_id={memory_id}")
return None
if _hit_namespace != "chat":
hit_variant, hit_kind = _hit_namespace.split(":", 1)
return self._format_productivity_get(
_hit_node,
kind=hit_kind,
variant=hit_variant,
request_id=request_id,
elapsed_ms=elapsed,
)
if vdb_node is not None:
return {
"memory_id": vdb_node.node_id,
"kind": "chat",
"variant": None,
"content": vdb_node.content,
"layer": vdb_node.layer.value,
"status": vdb_node.status.value if vdb_node.status else "active",
"is_latest": vdb_node.is_latest,
"supersedes": vdb_node.supersedes or [],
"superseded_by": vdb_node.superseded_by or [],
"tags": vdb_node.tags or [],
"memory_at": int(vdb_node.memory_at.timestamp()) if isinstance(vdb_node.memory_at, datetime) else None,
"gmt_created": int(vdb_node.gmt_created.timestamp()) if isinstance(vdb_node.gmt_created, datetime) else None,
"speculate": getattr(vdb_node, "speculate", None),
# 不透出 source_raw_memory_id:见 async_search 同处说明。
"user_id": vdb_node.user_id,
"agent_id": vdb_node.agent_id,
"session_id": vdb_node.session_id,
"owner": vdb_node.owner,
"confidence": vdb_node.confidence,
"custom": vdb_node.custom or {},
"meta": list((vdb_node.custom or {}).get("meta") or []),
"source": vdb_node.source,
"retrieval_source": "vdb",
"request_id": request_id,
"elapsed_ms": elapsed,
}
if graph_node is not None:
return {
"memory_id": graph_node.node_id,
"kind": "chat",
"variant": None,
"content": graph_node.content,
"layer": graph_node.layer.value if graph_node.layer else "",
"status": graph_node.status.value if graph_node.status else "active",
"is_latest": getattr(graph_node, "is_latest", True),
"supersedes": graph_node.supersedes or [],
"superseded_by": graph_node.superseded_by or [],
"tags": graph_node.tags or [],
"memory_at": int(graph_node.memory_at.timestamp()) if isinstance(graph_node.memory_at, datetime) else None,
"gmt_created": int(graph_node.gmt_created.timestamp()) if isinstance(graph_node.gmt_created, datetime) else None,
"speculate": getattr(graph_node, "speculate", None),
# 不透出 source_raw_memory_id:见 async_search 同处说明。
"user_id": graph_node.user_id,
"agent_id": graph_node.agent_id,
"session_id": getattr(graph_node, "session_id", "") or "",
"owner": getattr(graph_node, "owner", None),
"confidence": graph_node.confidence,
"custom": getattr(graph_node, "custom", {}) or {},
"source": getattr(graph_node, "source", "") or "",
"retrieval_source": "graph",
"request_id": request_id,
"elapsed_ms": elapsed,
}
return None
@_ensure_internal_loop
async def async_update(
self,
memory_id: str,
content: str,
*,
kind: Optional[MemoryKind] = None,
variant: Optional[MemoryVariant] = None,
task: Optional[str] = None,
) -> Dict[str, Any]:
"""
更新记忆内容(异步)。
namespace 解析与 get() 相同(_resolve_memory_namespace):kind/variant 都不
给时查 chat + working/coding 的经验与事实,命中多个 namespace 抛
AmbiguousMemoryIdError,用 kind/variant 消歧。chat 只认向量库里的节点
(与改动前一致)。
content 的映射:
- chat:替换正文并重嵌(原行为)
- experience:替换 solution(与 get() 返回的 content 同一字段);task
不变,除非另传 task=。solution 不进向量,只改它时不重嵌;改了 task
则删旧 key 向量行、重嵌 task + search_keys。updated_at 刷新。
- fact:替换 statement,active 事实重嵌其向量行。
task= 只对经验有效,给 chat / fact 传会 ValueError。
Returns:
{"success", "memory_id", "request_id", "elapsed_ms"};生产力条目额外带
"kind" / "variant"。不存在时 success=False + error_message。
"""
self._validate_memory_namespace(kind, variant)
if task is not None and kind in ("chat", "fact"):
raise ValueError("task is only valid when updating an experience")
matches, _, stores = await self._resolve_memory_namespace(
memory_id, kind=kind, variant=variant,
chat_lookups={
"chat:vdb": lambda: self._vector_store.get_by_id(memory_id),
},
)
namespace = matches[0][0] if matches else ""
if namespace and namespace != "chat":
hit_variant, hit_kind = namespace.split(":", 1)
if task is not None and hit_kind != "experience":
raise ValueError("task is only valid when updating an experience")
return await self._update_productivity(
stores[hit_variant], memory_id, content,
kind=hit_kind, variant=hit_variant, task=task,
)
if task is not None:
raise ValueError("task is only valid when updating an experience")
if not namespace and (kind not in (None, "chat") or variant is not None):
return self._productivity_not_found(
memory_id, kind=kind, variant=variant, op="update",
)
return await self._update_chat(memory_id, content)
@staticmethod
def _productivity_not_found(
memory_id: str, *, kind: Optional[str], variant: Optional[str], op: str,
) -> Dict[str, Any]:
request_id = str(uuid.uuid4())
logger.warning(
f"[{op}] not found: memory_id={memory_id} kind={kind} variant={variant}"
)
out: Dict[str, Any] = {
"success": False,
"memory_id": memory_id,
"kind": kind,
"variant": variant,
"request_id": request_id,
"elapsed_ms": 0.0,
"error_message": f"memory_id '{memory_id}' not found",
}
if op == "delete":
out.update({"deleted_count": 0, "partial": False, "error_code": None})
return out
async def _update_productivity(
self,
store,
memory_id: str,
content: str,
*,
kind: str,
variant: str,
task: Optional[str],
) -> Dict[str, Any]:
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
logger.info(
f"[update] memory_id={memory_id} namespace={variant}:{kind} "
f"new_content({len(content)} chars)"
)
if kind == "experience":
updated = await store.edit_experience(
memory_id, solution=content, task=task,
)
else:
updated = await store.edit_fact(memory_id, statement=content)
elapsed = round((time.perf_counter() - t0) * 1000, 2)
if updated is None:
# 解析之后、写入之前被并发删掉
return {
"success": False,
"memory_id": memory_id,
"kind": kind,
"variant": variant,
"request_id": request_id,
"elapsed_ms": elapsed,
"error_message": f"memory_id '{memory_id}' not found",
}
await self._maybe_store_op_log(
step="UPDATE",
request_id=request_id,
user_id=getattr(updated, "user_id", "") or "",
agent_id=getattr(updated, "agent_id", "") or "",
parsed={"memory_id": memory_id, "namespace": f"{variant}:{kind}"},
memory_ids=[memory_id],
elapsed_ms=elapsed,
)
return {
"success": True,
"memory_id": memory_id,
"kind": kind,
"variant": variant,
"request_id": request_id,
"elapsed_ms": elapsed,
}
async def _update_chat(self, memory_id: str, content: str) -> Dict[str, Any]:
"""chat 记忆的更新(原 async_update 逻辑)。
流程: get_by_id → 404 校验 → embed → 更新节点字段 → upsert → 记录历史
"""
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
logger.info(f"[update] memory_id={memory_id} new_content({len(content)} chars)")
# 获取旧节点
node = await self._vector_store.get_by_id(memory_id)
if node is None:
logger.warning(f"[update] not found: memory_id={memory_id}")
return {
"success": False,
"memory_id": memory_id,
"request_id": request_id,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_message": f"memory_id '{memory_id}' not found",
}
old_content = node.content
old_status = node.status.value
# 生成新 embedding
new_embedding = await self._embed_service.embed(content)
# 更新节点
node.content = content
node.embedding = new_embedding
await self._vector_store.upsert(node)
try:
from .pipelines._retrieval.retrieval_clauses import (
sync_retrieval_clause_index,
)
await sync_retrieval_clause_index(
vector_store=self._vector_store,
embed_service=self._embed_service,
nodes=[node],
enabled=bool(getattr(
self._config.recall, "retrieval_clauses_enabled", False
)),
min_words=int(getattr(
self._config.recall, "retrieval_clauses_min_words", 70
)),
clear_empty=True,
)
except Exception as e:
logger.warning("[retrieval-clause] client.update maintain failed: %s", e)
logger.info(f"[update] done: memory_id={memory_id}")
# 记录历史
if self._history_store is not None:
try:
uid = node.user_id or ""
agent_id = node.agent_id or "default"
isolation_key = self._build_isolation_key(uid, agent_id)
await self._history_store.record_update(
memory_id=memory_id,
old_content=old_content,
new_content=content,
old_status=old_status,
new_status=node.status.value,
change_reason="client.update",
isolation_key=isolation_key,
)
except Exception as e:
logger.warning(f"[update] history record failed: {e}")
return {
"success": True,
"memory_id": memory_id,
"request_id": request_id,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
}
def _backend_identity(self) -> Dict[str, str]:
"""解析当前 VDB 后端身份:provider / db_name / collection_name。
供 delete/get/list 等操作落 pipeline trace 用。没有数据库命名空间概念的后端
会返回空的 db_name。
"""
vs = self._vector_store
provider = ""
try:
provider = str(getattr(self._config.vector_store, "provider", "") or "")
except Exception:
provider = ""
return {
"provider": provider,
"db_name": str(getattr(vs, "_db_name", "") or ""),
"collection_name": str(getattr(vs, "_collection_name", "") or ""),
}
async def _maybe_store_op_log(
self,
*,
step: str,
request_id: str,
user_id: str = "",
agent_id: str = "",
session_id: str = "",
parsed: Optional[Dict[str, Any]] = None,
memory_ids: Optional[List[str]] = None,
elapsed_ms: float = 0.0,
event_at_ms: int = 0,
) -> None:
"""为 delete/get/list 等非 LLM 操作落一条 pipeline trace(best-effort)。
复用 pipeline_logs_v2 表:prompt/response 留空、token 为 0,结构化信息全部
放进 parsed(JSON)。受 MEMORY_PIPELINE_TRACE_ENABLED 控制,与 search 一致。
失败静默降级,绝不影响主操作。
event_at_ms: 事件真实发生时刻(epoch 毫秒)。绝大多数调用方是直接
`await _maybe_store_op_log(...)`,observability wrapper 首行的
`time.time()` 与调用点只差几微秒,无需传此参数(传 0 即可)。
**只有经 `create_task` / `ensure_future` 派发的调用方必须显式传**——
派发会让 wrapper 晚若干毫秒才执行,届时它自己打的时间戳已经偏晚,
导致 pipeline log 的 timestamp 与真实时序倒挂。
"""
if self._cache is None:
return
try:
from .utils.pipeline_observability import is_pipeline_trace_enabled
if not is_pipeline_trace_enabled():
return
await self._cache.store_pipeline_log(
request_id=request_id,
user_id=user_id or "",
agent_id=agent_id or "",
session_id=session_id or "",
step=step,
prompt="",
response="",
parsed=json.dumps(parsed or {}, ensure_ascii=False),
memory_ids=memory_ids or None,
elapsed_ms=float(elapsed_ms or 0.0),
event_at_ms=int(event_at_ms or 0),
)
except Exception as e:
logger.debug(f"[{step.lower()}] store op trace failed (non-fatal): {e}")
@_ensure_internal_loop
async def async_delete(
self,
memory_id: str,
*,
kind: Optional[MemoryKind] = None,
variant: Optional[MemoryVariant] = None,
) -> Dict[str, Any]:
"""
删除单条记忆(异步)。
namespace 解析与 get() 相同:kind/variant 都不给时查 chat(向量库 + 图库)
与 working/coding 的经验和事实,命中多个 namespace 抛
AmbiguousMemoryIdError。
- chat:原逻辑(见 _delete_chat)
- experience:删 SQLite 行 + 经验向量集合里它的全部 key 行
- fact:删 SQLite 行 + 事实向量集合里那一行;别处指向它的引用不清,
召回侧本就按存在性过滤死链
Returns:
{"success", "deleted_count", "request_id", "elapsed_ms", ...};
生产力条目额外带 "kind" / "variant"。不存在时 success=False、
deleted_count=0。
"""
self._validate_memory_namespace(kind, variant)
async def _get_graph():
if getattr(self, "_graph_store", None) is None:
return None
try:
return await self._graph_store.get_node(memory_id)
except Exception:
# 图库查不通交给 _delete_chat 报 delete_incomplete
return None
matches, _, stores = await self._resolve_memory_namespace(
memory_id, kind=kind, variant=variant,
chat_lookups={
"chat:vdb": lambda: self._vector_store.get_by_id(memory_id),
"chat:graph": _get_graph,
},
)
namespace = matches[0][0] if matches else ""
if namespace and namespace != "chat":
hit_variant, hit_kind = namespace.split(":", 1)
return await self._delete_productivity(
stores[hit_variant], memory_id, matches[0][1],
kind=hit_kind, variant=hit_variant,
)
if not namespace and (kind not in (None, "chat") or variant is not None):
return self._productivity_not_found(
memory_id, kind=kind, variant=variant, op="delete",
)
return await self._delete_chat(memory_id)
async def _delete_productivity(
self, store, memory_id: str, item, *, kind: str, variant: str,
) -> Dict[str, Any]:
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
logger.info(f"[delete] memory_id={memory_id} namespace={variant}:{kind}")
user_id = getattr(item, "user_id", "") or ""
if kind == "experience":
deleted = await store.delete(memory_id, user_id=user_id)
else:
deleted = await store.delete_fact(memory_id, user_id=user_id or None)
elapsed = round((time.perf_counter() - t0) * 1000, 2)
await self._maybe_store_op_log(
step="DELETE",
request_id=request_id,
user_id=user_id,
agent_id=getattr(item, "agent_id", "") or "",
parsed={
"memory_id": memory_id,
"namespace": f"{variant}:{kind}",
"found": True,
"deleted_count": 1 if deleted else 0,
},
memory_ids=[memory_id],
elapsed_ms=elapsed,
)
return {
"success": bool(deleted),
"deleted_count": 1 if deleted else 0,
"memory_id": memory_id,
"kind": kind,
"variant": variant,
"partial": False,
"error_code": None,
"request_id": request_id,
"elapsed_ms": elapsed,
}
async def _delete_chat(self, memory_id: str) -> Dict[str, Any]:
"""
删除单条 chat 记忆(原 async_delete 逻辑)。
同时删除向量库和图数据库(如有)中的对应节点。
演化链处理:
- 如果删除的是链头(is_latest=True + supersedes 非空),
将前驱节点恢复为 is_latest=True + status=ACTIVE
- 如果删除的是链中间节点,将其 supersedes 指向的前驱重新挂到后继的 supersedes 上
Graph evidence 处理:
- 删除 Graph 中该节点 + 关联的 VdbRef 影子节点 + DERIVED_FROM 边
Returns:
{"success", "deleted_count", "request_id", "elapsed_ms"}
"""
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
logger.info(f"[delete] memory_id={memory_id}")
# 先获取节点信息(用于链修复 + history + tag 清理)
old_content = ""
old_tags: List[str] = []
old_user_id = ""
old_isolation_key = ""
node = await self._vector_store.get_by_id(memory_id)
if node is not None:
old_content = node.content
try:
old_tags = list(node.tags or [])
except Exception:
old_tags = []
old_user_id = node.user_id or ""
try:
old_isolation_key = node.get_isolation_key() or ""
except Exception:
old_isolation_key = ""
graph_node = None
graph_inspection_failed = False
if self._graph_store is not None:
try:
graph_node = await self._graph_store.get_node(memory_id)
except Exception as e:
graph_inspection_failed = True
logger.warning(f"[delete] graph_store lookup failed: {e}")
# ── 演化链修复(VDB 删除前执行)──
chain_repaired = False
if node is not None:
await self._repair_evolution_chain_on_delete(node)
chain_repaired = bool(node.supersedes or node.superseded_by)
# 删除向量库
deleted = await self._vector_store.delete(memory_id)
try:
await self._vector_store.delete_retrieval_clauses(memory_id)
except Exception as e:
logger.debug("[retrieval-clause] delete cleanup failed: %s", e)
# 删除图数据库节点 + 清理 evidence(如有)
graph_deleted = False
graph_cleanup_ok = True
if self._graph_store is not None:
if graph_node is not None:
try:
graph_deleted = bool(await self._graph_store.delete_node(memory_id))
except Exception as e:
logger.warning(f"[delete] graph_store delete failed: {e}")
# 清理该节点作为 VdbRef 被其他 Schema 引用的情况
if node is not None or graph_node is not None:
graph_cleanup_ok = await self._cleanup_graph_evidence(memory_id)
found = node is not None or graph_node is not None
stores_complete = (
not graph_inspection_failed
and (node is None or bool(deleted))
and (graph_node is None or graph_deleted)
and graph_cleanup_ok
)
success = found and stores_complete
partial = found and not stores_complete and (bool(deleted) or graph_deleted)
deleted_count = 1 if success else 0
# 清理 tag_index:此 memory 删除后,检查其每个 tag 是否还有其他 memory 引用;
# 无引用则从 tag_index 中移除。失败静默降级,不影响 delete 语义。
if deleted and old_tags and old_user_id:
try:
from .pipelines._retrieval import tag_index as _tag_index_helper
removed = await _tag_index_helper.cleanup_tags_on_delete(
vector_store=self._vector_store,
user_id=old_user_id,
tags=old_tags,
isolation_key=old_isolation_key,
)
if removed:
logger.debug(f"[delete] tag_index cleaned {removed} orphan tags")
except Exception as e:
logger.debug(f"[delete] tag_index cleanup failed (non-fatal): {e}")
# 记录历史
if self._history_store is not None and deleted:
try:
uid = (node.user_id if node else "") or ""
agent_id = (node.agent_id if node else "") or "default"
isolation_key = self._build_isolation_key(uid, agent_id)
await self._history_store.record_delete(
memory_id=memory_id,
content=old_content,
isolation_key=isolation_key,
)
except Exception as e:
logger.warning(f"[delete] history record failed: {e}")
logger.info(f"[delete] done: deleted_count={deleted_count}")
# pipeline trace(best-effort,受 MEMORY_PIPELINE_TRACE_ENABLED 控制)
_ident = self._backend_identity()
_parsed = {
"memory_id": memory_id,
**_ident,
"found": found,
"vdb_deleted": bool(deleted),
"graph_found": graph_node is not None,
"graph_deleted": graph_deleted,
"graph_cleanup_ok": graph_cleanup_ok,
"graph_inspection_failed": graph_inspection_failed,
"deleted_count": deleted_count,
"partial": partial,
"chain_repaired": chain_repaired,
}
if node is not None:
_parsed["user_id"] = node.user_id or ""
_parsed["agent_id"] = node.agent_id or ""
await self._maybe_store_op_log(
step="DELETE",
request_id=request_id,
user_id=(node.user_id if node else "") or "",
agent_id=(node.agent_id if node else "") or "",
parsed=_parsed,
memory_ids=[memory_id],
elapsed_ms=round((time.perf_counter() - t0) * 1000, 2),
)
return {
"success": success,
"deleted_count": deleted_count,
"partial": partial,
"vdb_deleted": bool(deleted),
"graph_deleted": graph_deleted,
"graph_cleanup_ok": graph_cleanup_ok,
"error_code": (
"delete_incomplete"
if graph_inspection_failed or (found and not stores_complete)
else None
),
"request_id": request_id,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
}
async def _acquire_graph_store_for_purge(self):
"""
获取用于 purge 的 GraphStore(与 mode 无关)。
ultra 模式复用常驻连接;pro/lite 在检测到遗留图数据时临时打开并 purge 后关闭。
Returns:
(graph_store | None, ephemeral: bool) — ephemeral=True 时调用方须 close。
"""
if self._graph_store is not None:
return self._graph_store, False
from .data.graph_store import create_graph_store, graph_data_may_exist
if not graph_data_may_exist(self._config):
return None, False
gs = create_graph_store(self._config)
await gs.initialize()
if getattr(gs, "_available", True) is False:
logger.warning(
"[purge] ephemeral graph store unavailable (e.g. Kuzu file lock); "
"skip graph purge"
)
await gs.close()
return None, False
provider = getattr(self._config.graph_store, "provider", None) or "kuzu"
logger.info(
f"[purge] opened ephemeral GraphStore for delete_all "
f"(mode={self._mode}, provider={provider})"
)
return gs, True
async def _purge_graph_data(
self,
user_id: str,
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
) -> tuple:
"""按 user 范围清理图数据(VDB 与 mode 无关,图库亦同)。
Returns:
(deleted_count: int, elapsed_ms: float, ephemeral: bool)
供调用方落 DELETE_ALL_GRAPH pipeline log,区分
"VDB 慢" 与 "Graph 临时建连慢(ephemeral=True 时 initialize() 耗时)"。
"""
_t = time.perf_counter()
gs, ephemeral = await self._acquire_graph_store_for_purge()
if gs is None:
return 0, round((time.perf_counter() - _t) * 1000, 2), False
try:
n = await gs.delete_by_metadata(
user_id=user_id, agent_id=agent_id, session_id=session_id,
)
elapsed_ms = round((time.perf_counter() - _t) * 1000, 2)
logger.info(
f"[delete_all] graph purged: user={user_id} agent={agent_id} "
f"session={session_id} memory_nodes={n} ephemeral={ephemeral}"
)
return n, elapsed_ms, ephemeral
except Exception as e:
logger.warning(f"[delete_all] graph_store purge failed: {e}")
return 0, round((time.perf_counter() - _t) * 1000, 2), ephemeral
finally:
if ephemeral:
await gs.close()
@_ensure_internal_loop
async def async_delete_all(
self,
*,
user_id: str = "",
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
删除用户的记忆(异步)。
默认删除该用户的所有记忆。可通过 agent_ids/session_ids 缩小范围。
- agent_ids=[], session_ids=[] → 删除 user 下所有 agent、所有 session
- agent_ids=["a1"] → 删除 user 下 a1 的所有 session
- agent_ids=["a1"], session_ids=["s1","s2"] → 删除指定组合
Returns:
{"success", "deleted_count", "request_id", "elapsed_ms"}
"""
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
uid = user_id
effective_agent_ids = agent_ids if agent_ids is not None else []
effective_session_ids = session_ids if session_ids is not None else []
total_deleted = 0
total_graph_deleted = 0
# 分段耗时聚合(供 DELETE_ALL_SUMMARY 落库)
vdb_ms_total = 0.0
graph_ms_total = 0.0
rounds = 0 # 实际执行了几轮(agent×session 组合数)
async def _do_round(
aid: Optional[str] = None,
sid: Optional[str] = None,
) -> None:
"""一个 (agent, session) 组合的 VDB + Graph 删除,各落一条 pipeline log。"""
nonlocal total_deleted, total_graph_deleted, vdb_ms_total, graph_ms_total, rounds
rounds += 1
# ── VDB 删除 ──────────────────────────────────────────────
_t_vdb = time.perf_counter()
try:
cnt = await self._vector_store.delete_by_metadata(
user_id=uid,
agent_id=aid,
session_id=sid,
)
except Exception as e:
logger.warning(f"[delete_all] vdb delete failed aid={aid} sid={sid}: {e}")
cnt = -1
try:
await self._vector_store.delete_retrieval_clauses_by_metadata(
user_id=uid,
agent_id=aid,
session_id=sid,
)
except Exception as e:
logger.debug(
"[retrieval-clause] delete_all cleanup failed "
"aid=%s sid=%s: %s", aid, sid, e,
)
_vdb_ms = round((time.perf_counter() - _t_vdb) * 1000, 2)
if cnt and cnt > 0:
total_deleted += cnt
vdb_ms_total += _vdb_ms
await self._maybe_store_op_log(
step="DELETE_ALL_VDB",
request_id=request_id,
user_id=uid,
agent_id=aid or "",
parsed={
"agent_id": aid,
"session_id": sid,
"deleted_count": cnt if cnt and cnt > 0 else 0,
"vdb_ms": _vdb_ms,
},
elapsed_ms=_vdb_ms,
)
# ── Graph 删除 ────────────────────────────────────────────
g_cnt, _graph_ms, ephemeral = await self._purge_graph_data(
uid, agent_id=aid, session_id=sid,
)
total_graph_deleted += g_cnt
graph_ms_total += _graph_ms
await self._maybe_store_op_log(
step="DELETE_ALL_GRAPH",
request_id=request_id,
user_id=uid,
agent_id=aid or "",
parsed={
"agent_id": aid,
"session_id": sid,
"graph_deleted_count": g_cnt,
"graph_ms": _graph_ms,
# ephemeral=True 时含临时 initialize() 开销,是 Graph 超时的常见原因
"ephemeral": ephemeral,
},
elapsed_ms=_graph_ms,
)
# ── 路由到具体的删除模式 ──────────────────────────────────────
if not effective_agent_ids and not effective_session_ids:
logger.info(f"[delete_all] deleting all for user={uid}")
await _do_round()
elif effective_agent_ids and not effective_session_ids:
for aid in effective_agent_ids:
logger.info(f"[delete_all] deleting user={uid} agent={aid}")
await _do_round(aid=aid)
else:
a_ids = effective_agent_ids or ["default_agent"]
for aid in a_ids:
for sid in effective_session_ids:
logger.info(f"[delete_all] deleting user={uid} agent={aid} session={sid}")
await _do_round(aid=aid, sid=sid)
elapsed_ms = round((time.perf_counter() - t0) * 1000, 2)
logger.info(
f"[delete_all] done: vdb_deleted={total_deleted} "
f"graph_memory_nodes={total_graph_deleted} mode={self._mode}"
)
# ── 总览 log ──────────────────────────────────────────────────
await self._maybe_store_op_log(
step="DELETE_ALL_SUMMARY",
request_id=request_id,
user_id=uid,
parsed={
"total_deleted": total_deleted,
"graph_deleted": total_graph_deleted,
"rounds": rounds,
"vdb_ms_total": round(vdb_ms_total, 2),
"graph_ms_total": round(graph_ms_total, 2),
# scope:方便 inspector 一眼看出删的是什么范围
"scope": (
"all" if not effective_agent_ids and not effective_session_ids
else f"agents={effective_agent_ids}" if not effective_session_ids
else f"agents={effective_agent_ids} sessions={effective_session_ids}"
),
},
elapsed_ms=elapsed_ms,
)
return {
"success": True,
"deleted_count": total_deleted,
"graph_deleted_count": total_graph_deleted,
"request_id": request_id,
"elapsed_ms": elapsed_ms,
}
# ================================================================
# List Memories API(异步)
# ================================================================
@staticmethod
def _memory_node_to_list_item(node) -> Dict[str, Any]:
"""将 MemoryNode 序列化为 list API 条目。"""
_memory_at = None
if isinstance(node.memory_at, datetime):
_memory_at = int(node.memory_at.timestamp())
_gmt_created = None
if isinstance(node.gmt_created, datetime):
_gmt_created = int(node.gmt_created.timestamp())
if _gmt_created is None and isinstance(getattr(node, "valid_from", None), datetime):
_gmt_created = int(node.valid_from.timestamp())
return {
"memory_id": node.node_id,
"content": node.content,
"layer": node.layer.value if node.layer else "",
"status": node.status.value if node.status else "active",
"memory_at": _memory_at,
"gmt_created": _gmt_created,
"user_id": node.user_id,
"agent_id": node.agent_id,
"session_id": getattr(node, "session_id", None),
"source": getattr(node, "source", None),
"speculate": getattr(node, "speculate", None),
# 不透出 source_raw_memory_id:见 async_search 同处说明。
"tags": node.tags or [],
"custom": node.custom or {},
# 调用方自定义 meta(透传负载)。与 search / get 一致地放在顶层,
# 免得调用方为不同接口写两套取值逻辑;custom 内含内部字段,
# 上层不应直接透出给业务方。
"meta": list((node.custom or {}).get("meta") or []),
}
@staticmethod
def _normalize_layers(layers: Optional[List[Any]]) -> Optional[List]:
"""把 str / MemoryLayer 混合的 layers 规范化为去重后的 MemoryLayer 列表。
接受字符串是为了与 search 侧对齐(ReadRequest.layers 为 List[str]),
并复用 from_string 的旧编号兼容(如 l5_schema → l6_schema)。
非法值直接抛 ValueError,由调用方(App 层)转成 400,不静默忽略——
静默忽略会让调用方以为筛选生效,实际拿到的是全量。
"""
from .models.memory import MemoryLayer
if not layers:
return None
out: List[MemoryLayer] = []
for item in layers:
layer = item if isinstance(item, MemoryLayer) else MemoryLayer.from_string(str(item))
if layer not in out:
out.append(layer)
return out
@staticmethod
def _normalize_statuses(statuses: Optional[List[Any]]) -> Optional[List]:
"""Normalize public lifecycle filters without inventing product states."""
from .models.memory import MemoryStatus
if not statuses:
return None
out: List[MemoryStatus] = []
for item in statuses:
status = item if isinstance(item, MemoryStatus) else MemoryStatus(str(item).lower())
if status not in out:
out.append(status)
return out
@staticmethod
def _sort_memory_nodes(nodes: List, *, order: str) -> List:
reverse = (order.lower() != "asc")
def _sort_key(n):
val = n.memory_at if n.memory_at is not None else n.gmt_created
if val is None:
val = getattr(n, "valid_from", None)
if val is None:
return datetime.min
if isinstance(val, datetime):
return val
return datetime.min
nodes.sort(key=_sort_key, reverse=reverse)
return nodes
async def _list_vdb_nodes(
self,
*,
user_id: str,
agent_id: Optional[str],
user_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
layers: Optional[List] = None,
statuses: Optional[List] = None,
) -> List:
"""chat 路径的全部节点(已过滤、未排序)。list 与 stats 共用同一套口径。"""
from .models.memory import MemoryLayer, MemoryStatus
from .pipelines._retrieval.user_level_l0 import collapse_user_level_l0_hits
# Default stays ACTIVE for compatibility. An explicit filter is how
# callers inspect SDK-owned superseded/shadow lifecycle records.
effective_statuses = statuses or [MemoryStatus.ACTIVE]
# 不叠加 only_latest:正常数据下 writer 对 is_latest/status 成对写,两者等效。
# 列表页只需 payload(content/tags/custom/链头信息)与排序字段,
# 不涉及向量运算,一概 with_vectors=False,避免拉回大量 embedding。
#
# L1_RAW 的排除由向量库侧完成(allow_raw_layer 默认 False,条件随请求
# 下发,raw 不出库)。此前是把 raw 一并捞回进程再用 Python 筛掉,
# 既让原始对话经过本进程,又白拉一遍最大的那一层,已移除。
all_nodes = await self._vector_store.list_by_user(
user_id=user_id,
agent_id=agent_id,
user_ids=user_ids,
agent_ids=agent_ids,
status_filter=effective_statuses,
layers=layers,
with_vectors=False,
allow_raw_layer=bool(
layers is not None and MemoryLayer.L1_RAW in layers
),
)
# L0 是 user-level,不应被 agent 过滤。默认列表或显式包含 L0 时,额外按
# user 查询 L0,并把历史多个 agent-level 链头折叠为每个用户一条。
# L0 已无 agent 隔离,缺少 user 范围时必须跳过,避免返回其他用户画像。
include_l0 = layers is None or MemoryLayer.L0_BASIC_INFO in layers
if include_l0 and (user_id or user_ids):
all_nodes = [n for n in all_nodes if n.layer != MemoryLayer.L0_BASIC_INFO]
l0_nodes = await self._vector_store.list_by_user(
user_id=user_id,
user_ids=user_ids,
status_filter=effective_statuses,
layers=[MemoryLayer.L0_BASIC_INFO],
with_vectors=False,
)
l0_hits = collapse_user_level_l0_hits([
{"node_id": n.node_id, "node": n, "score": 1.0}
for n in l0_nodes
])
all_nodes.extend(hit["node"] for hit in l0_hits if hit.get("node") is not None)
if session_ids:
wanted_sessions = set(session_ids)
all_nodes = [
node for node in all_nodes if node.session_id in wanted_sessions
]
if include_sources:
wanted_sources = set(include_sources)
all_nodes = [
node for node in all_nodes if node.source in wanted_sources
]
if exclude_sources:
blocked_sources = set(exclude_sources)
all_nodes = [
node for node in all_nodes if node.source not in blocked_sources
]
return all_nodes
async def _list_vdb_bucket(
self,
*,
user_id: str,
agent_id: Optional[str],
limit: int,
offset: int,
order: str,
user_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
layers: Optional[List] = None,
statuses: Optional[List] = None,
with_evolution: bool = False,
) -> Dict[str, Any]:
all_nodes = await self._list_vdb_nodes(
user_id=user_id, agent_id=agent_id,
user_ids=user_ids, agent_ids=agent_ids,
session_ids=session_ids,
include_sources=include_sources,
exclude_sources=exclude_sources,
layers=layers, statuses=statuses,
)
all_nodes = self._sort_memory_nodes(all_nodes, order=order)
total = len(all_nodes)
page_nodes = all_nodes[offset: offset + limit]
items = [self._memory_node_to_list_item(n) for n in page_nodes]
if with_evolution:
await self._attach_evolution_chains(page_nodes, items)
return {
"memories": items,
"total": total,
"limit": limit,
"offset": offset,
}
async def _attach_evolution_chains(self, nodes: List, items: List[Dict[str, Any]]) -> None:
"""就地给 list 条目附加 evolution_chain,结构与 search 侧一致。
只展开当前页:回溯要按链逐条查库,整表展开会随数据量线性放大,
而列表页一次只渲染一页。
只附加字段、不增删条目。expand_evolution_chains 自带同链去重(search 侧
可能召回同链多个节点),但 list 已按 status=ACTIVE 过滤,历史版本必为
SUPERSEDED,同链多条不会出现;即便出现也不能让 list 少返回条目,
故按链头 node_id 映射回原条目,而非直接采用其输出。
"""
from .pipelines._retrieval.evolution import expand_evolution_chains
linked = [
n for n in nodes
if getattr(n, "supersedes", None) or getattr(n, "superseded_by", None)
]
if not linked:
return
try:
expanded = await expand_evolution_chains(
self._vector_store,
[{"node_id": n.node_id, "score": 0.0, "node": n} for n in linked],
)
except Exception as e:
# 演化链是附加信息,取不到不应让整个列表失败
logger.warning(f"[list] evolution chain expand failed: {e}")
return
chains = {
exp.get("node_id"): exp["evolution_chain"]
for exp in expanded
if isinstance(exp, dict) and exp.get("evolution_chain")
}
for item in items:
chain = chains.get(item.get("memory_id"))
if chain:
item["evolution_chain"] = chain
async def _list_graph_bucket(
self,
*,
user_id: str,
agent_id: Optional[str],
limit: int,
offset: int,
order: str,
user_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
layers: Optional[List] = None,
statuses: Optional[List] = None,
) -> Optional[Dict[str, Any]]:
"""列出图库 L6/L7 节点;无图数据、不可用或 layers 不含图层时返回 None。"""
from .models.memory import MemoryLayer, MemoryNode, MemoryStatus
graph_layers = [MemoryLayer.L6_SCHEMA, MemoryLayer.L7_INTENTION]
if layers:
graph_layers = [layer for layer in graph_layers if layer in layers]
# 指定的 layers 全在 VDB 侧,无需连图库(连接本身有开销)
if not graph_layers:
return None
gs, ephemeral = await self._acquire_graph_store_for_purge()
if gs is None:
return None
# 图库按 isolation_key 组织,无 in 查询能力,多值只能按组合逐个拉后合并
effective_uids = list(user_ids) if user_ids else ([user_id] if user_id else [])
effective_aids = list(agent_ids) if agent_ids else [agent_id or "default_agent"]
iks = [
MemoryNode.build_isolation_key(u, a)
for u in effective_uids
for a in effective_aids
]
try:
async def _fetch(ik: str, layer) -> List:
try:
return await gs.get_all_nodes(ik, layer=layer, limit=10_000)
except Exception as e:
logger.warning(
f"[list] graph list ik={ik} layer={layer.value} failed: {e}"
)
return []
chunks = await asyncio.gather(*[
_fetch(ik, layer)
for ik in iks
for layer in graph_layers
])
graph_nodes = [n for chunk in chunks for n in chunk]
effective_statuses = statuses or [MemoryStatus.ACTIVE]
graph_nodes = [
node for node in graph_nodes
if getattr(node, "status", None) in effective_statuses
]
if session_ids:
wanted_sessions = set(session_ids)
graph_nodes = [
node for node in graph_nodes if node.session_id in wanted_sessions
]
if include_sources:
wanted_sources = set(include_sources)
graph_nodes = [
node for node in graph_nodes if node.source in wanted_sources
]
if exclude_sources:
blocked_sources = set(exclude_sources)
graph_nodes = [
node for node in graph_nodes if node.source not in blocked_sources
]
graph_nodes = self._sort_memory_nodes(graph_nodes, order=order)
total = len(graph_nodes)
page_nodes = graph_nodes[offset: offset + limit]
out = {
"nodes": [self._memory_node_to_list_item(n) for n in page_nodes],
"total": total,
"limit": limit,
"offset": offset,
}
# 单组合时保持原字段形态,避免既有调用方读 isolation_key 失效
if len(iks) == 1:
out["isolation_key"] = iks[0]
else:
out["isolation_keys"] = iks
return out
finally:
if ephemeral:
await gs.close()
async def _list_coding(
self,
*,
user_id: str,
workspace_id: str,
branch: Optional[str],
limit: int,
offset: int,
request_id: str,
t0: float,
statuses: Optional[List] = None,
variant: Optional[str] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""按 workspace_id(+可选 branch)纯过滤列出经验层完整详情。
枚举而非召回,所以只有经验层、不带事实层。无 query、无语义排序,按
updated_at 倒序。fail-safe:失败返回空列表。
variant 给定时读该变体自己的库(没写过的变体不建空库,直接返回空),
workspace_id 为空表示该 user 的全部经验。未给定时沿用"最近一次用的那份"。
include_sources / exclude_sources 按 source 列在 SQL 里过滤(排除优先),
分页与 total 都在过滤之后计算。
"""
from .utils.source_filter import normalize_source_filters
include_sources, exclude_sources = normalize_source_filters(
include_sources, exclude_sources,
)
empty = {
"request_id": request_id,
"scene": "productivity",
"experiences": [],
"total": 0,
"limit": limit,
"offset": offset,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
}
if variant is not None:
empty["variant"] = variant
from .models.memory import MemoryStatus
if statuses and MemoryStatus.ACTIVE not in statuses:
return empty
try:
if variant is None:
await self._get_coding_writer() # 副作用:lazy init store
store = self._coding_store
else:
store = await self._coding_store_for_exact_get(variant)
except Exception as e:
logger.warning(f"[list-coding] init failed: {e}")
return empty
if store is None:
return empty
try:
mems = await store.list_by_workspace(
user_id=user_id, workspace_id=workspace_id or "", branch=branch,
limit=limit, offset=offset,
include_sources=include_sources or None,
exclude_sources=exclude_sources or None,
)
total = (await store.stats(
user_id, workspace_id=workspace_id or None, branch=branch,
include_sources=include_sources or None,
exclude_sources=exclude_sources or None,
))["count"]
except Exception as e:
logger.warning(f"[list-coding] list_by_workspace failed: {e}")
return empty
out_memories = [
{
"experience_id": d.experience_id,
"agent_id": getattr(d, "agent_id", "") or "",
"task": d.task,
"search_keys": list(d.search_keys),
"solution": d.solution,
"boundary_envs": d.boundary_envs,
"workspace_id": d.workspace_id,
"branch": d.branch,
"files": list(d.files),
"source_fact_ids": list(d.source_fact_ids),
"confidence": d.confidence,
"status": MemoryStatus.ACTIVE.value,
"memory_at": _epoch_seconds(d.memory_at),
"created_at": d.created_at.isoformat() if d.created_at else None,
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
"source": d.source,
}
for d in mems
]
elapsed = round((time.perf_counter() - t0) * 1000, 2)
logger.info(
f"[list-coding] user={user_id} workspace_id={workspace_id!r} "
f"branch={branch!r} returned={len(out_memories)} elapsed={elapsed}ms"
)
out: Dict[str, Any] = {
"request_id": request_id,
"scene": "productivity",
"experiences": out_memories,
# 真实总数,不是本页条数:分页靠它判断是否还有下一页
"total": total,
"limit": limit,
"offset": offset,
"elapsed_ms": elapsed,
}
if variant is not None:
out["variant"] = variant
return out
@staticmethod
def _list_scene(scene: Optional[str]) -> Optional[str]:
"""list/stats 的 scene:None(原有路由)| chat | coding | working | all。"""
if scene is None:
return None
value = str(scene).strip().lower()
if value not in ("chat", "coding", "working", "all"):
raise ValueError(
f"scene must be chat, coding, working or all, got {scene!r}"
)
return value
@staticmethod
def _chat_list_item_unified(node) -> Dict[str, Any]:
"""chat 节点 → list(scene="all") 统一条目(在原 list 条目上加字段)。"""
item = HyMemoryClient._memory_node_to_list_item(node)
created = item.get("gmt_created")
updated = _epoch_seconds(getattr(node, "gmt_modified", None))
item.update({
"scene": "chat",
"kind": "chat",
"source": item.get("source") or "default",
"created_at": created,
"updated_at": updated if updated is not None else created,
})
return item
async def _list_all_scenes(
self,
*,
user_id: str,
agent_id: Optional[str],
user_ids: Optional[List[str]],
agent_ids: Optional[List[str]],
session_ids: Optional[List[str]],
include_sources: Optional[List[str]],
exclude_sources: Optional[List[str]],
layers: Optional[List],
statuses: Optional[List],
workspace_id: Optional[str],
branch: Optional[str],
limit: int,
offset: int,
order: str,
request_id: str,
t0: float,
) -> Dict[str, Any]:
"""list_memories(scene="all"):chat + working + coding 按更新时间合并分页。
分页做法(k 路归并的前缀):合并序的前 offset+limit 条,必然落在每一路
各自的前 offset+limit 条里。所以生产力两路只向 SQLite 要
``ORDER BY updated_at DESC LIMIT offset+limit``(有 updated_at 索引),
total 用 COUNT 聚合;chat 路本来就是整表读回再排序(VDB 没有按时间的
有序分页),与 scene=None 的 chat 列表开销相同。三路合并后按
(updated_at desc, scene, memory_id) 排序再切 [offset, offset+limit)。
翻页越深,生产力两路取的前缀越长(O(offset+limit)),但从不整表扫描。
"""
from .models.memory import MemoryStatus
from .utils.source_filter import normalize_source_filters
if str(order or "desc").strip().lower() != "desc":
raise ValueError("list_memories(scene='all') only supports order='desc'")
include, exclude = normalize_source_filters(include_sources, exclude_sources)
window = max(0, int(offset)) + max(0, int(limit))
nodes = await self._list_vdb_nodes(
user_id=user_id, agent_id=agent_id,
user_ids=user_ids, agent_ids=agent_ids,
session_ids=session_ids,
include_sources=include or None,
exclude_sources=exclude or None,
layers=layers, statuses=statuses,
)
# 排序键 (-更新时间(亚秒精度), 路序, 本路名次):本路名次就是该路自己的
# 排序(生产力路 = SQL 的 updated_at DESC, experience_id ASC),这样合并序
# 在每一路内部与那一路的取数顺序一致,"只取每路前 offset+limit 条" 才严格成立。
lane_order = {lane: index for index, lane in enumerate(_SCENE_LANES)}
keyed: List[Tuple[Tuple[float, int, int], Dict[str, Any]]] = []
def _chat_ts(node) -> float:
for value in (
getattr(node, "gmt_modified", None),
getattr(node, "gmt_created", None),
getattr(node, "valid_from", None),
):
stamp = _epoch_float(value)
if stamp is not None:
return stamp
return float("-inf")
chat_sorted = sorted(
nodes, key=lambda n: (-_chat_ts(n), str(getattr(n, "node_id", "")))
)
for position, node in enumerate(chat_sorted):
keyed.append((
(-_chat_ts(node), lane_order["chat"], position),
self._chat_list_item_unified(node),
))
totals: Dict[str, int] = {"chat": len(nodes), "working": 0, "coding": 0}
errors: Dict[str, str] = {}
include_productivity = not statuses or MemoryStatus.ACTIVE in statuses
productivity_user = user_id or (user_ids[0] if user_ids else "")
for variant in ("working", "coding"):
if not include_productivity:
continue
try:
store = await self._coding_store_for_exact_get(variant)
if store is None:
continue
mems = await store.list_by_workspace(
user_id=productivity_user,
workspace_id=workspace_id or "", branch=branch,
limit=window, offset=0,
include_sources=include or None,
exclude_sources=exclude or None,
)
totals[variant] = int((await store.stats(
productivity_user,
workspace_id=workspace_id or None, branch=branch,
include_sources=include or None,
exclude_sources=exclude or None,
))["count"])
except Exception as e:
logger.warning(f"[list-all] {variant} lane failed: {e}")
errors[variant] = f"{type(e).__name__}: {e}"
continue
for position, d in enumerate(mems):
payload = d.to_dict()
payload["status"] = MemoryStatus.ACTIVE.value
stamp = _epoch_float(
d.updated_at or d.created_at, naive_is_utc=True,
)
keyed.append((
(-(stamp if stamp is not None else float("-inf")),
lane_order[variant], position),
self._unify_productivity_item(
payload, kind="experience", scene=variant,
),
))
keyed.sort(key=lambda entry: entry[0])
page = [item for _, item in keyed[offset: offset + limit]]
total = sum(totals.values())
elapsed = round((time.perf_counter() - t0) * 1000, 2)
logger.info(
f"[list-all] user={user_ids or user_id} totals={totals} "
f"returned={len(page)} offset={offset} limit={limit} elapsed={elapsed}ms"
)
return {
"request_id": request_id,
"scene": "all",
"memories": page,
"total": total,
"totals": totals,
"errors": errors,
"limit": limit,
"offset": offset,
"elapsed_ms": elapsed,
}
@_ensure_internal_loop
async def async_memory_stats(
self,
*,
user_id: str = "",
statuses: Optional[List[Any]] = None,
) -> Dict[str, Any]:
"""每类记忆的数量与最新时间(异步)。字段见同步版 memory_stats。"""
from .models.memory import MemoryStatus
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
effective_statuses = self._normalize_statuses(statuses)
def _epoch(value: Optional[datetime]) -> Optional[int]:
return int(value.timestamp()) if isinstance(value, datetime) else None
nodes = await self._list_vdb_nodes(
user_id=user_id, agent_id=None, statuses=effective_statuses,
)
created = [
stamp for stamp in (
_epoch(n.gmt_created) or _epoch(getattr(n, "valid_from", None))
for n in nodes
) if stamp is not None
]
updated = [
stamp for stamp in (_epoch(getattr(n, "gmt_modified", None)) for n in nodes)
if stamp is not None
]
scenes: Dict[str, Dict[str, Any]] = {
"chat": {
"count": len(nodes),
"latest_created_at": max(created) if created else None,
"latest_updated_at": max(updated or created) if (updated or created) else None,
}
}
# 经验层没有生命周期字段,只算 ACTIVE;没写过的变体不建空库。
include_productivity = (
not effective_statuses or MemoryStatus.ACTIVE in effective_statuses
)
for variant in ("coding", "working"):
stats = {"count": 0, "latest_created_at": None, "latest_updated_at": None, "facts": 0}
if include_productivity:
store = await self._coding_store_for_exact_get(variant)
if store is not None:
raw = await store.stats(user_id)
stats = {
"count": raw["count"],
"latest_created_at": _epoch_seconds(raw["latest_created_at"], naive_is_utc=True),
"latest_updated_at": _epoch_seconds(raw["latest_updated_at"], naive_is_utc=True),
"facts": raw["facts"],
}
scenes[variant] = stats
elapsed = round((time.perf_counter() - t0) * 1000, 2)
total = sum(int(item["count"]) for item in scenes.values())
logger.info(
f"[stats] user={user_id} total={total} "
+ " ".join(f"{name}={item['count']}" for name, item in scenes.items())
+ f" elapsed={elapsed}ms"
)
return {
"total": total,
"scenes": scenes,
"request_id": request_id,
"elapsed_ms": elapsed,
}
@_ensure_internal_loop
async def async_list_memories(
self,
*,
user_id: str = "",
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0,
order: str = "desc",
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
user_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
session_ids: Optional[List[str]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
with_evolution: bool = False,
scene: Optional[str] = None,
) -> Dict[str, Any]:
"""
列出用户的记忆(异步)。
VDB 与图库分桶返回(图库仅在存在 Kuzu/Neo4j 数据时包含 graph 键)。
user_ids / agent_ids(多值,默认空):
- 提供时优先于单值 user_id / agent_id,一次返回多个用户(或多个 agent)
的记忆并集,分页在合并排序后统一施加。
- 每条记忆自带 user_id / agent_id,调用方据此追溯归属。
- coding 路径(workspace_id 非空)不支持多值,仅取单值 user_id。
layers(默认空):
- 只返回指定层级,接受字符串或 MemoryLayer(与 search 侧写法对齐);
非法值抛 ValueError。
- 不传时行为与改动前一致:返回 L1_RAW 以外的全部层级。
显式指定则完全以传入为准,包含只要 L1_RAW 的场景。
- 指定的层级都不在图库(L6/L7)时跳过图库查询,返回中省略 graph 键。
statuses(默认空):
- 不传时保持兼容,只返回 ACTIVE。
- 可传 MemoryStatus 或其字符串值,读取 SDK 内现存的 superseded /
shadow / negated / conflicted / archived 记录。
- delete 是物理删除,不存在可列出的 deleted 状态。
with_evolution(默认 False):
- 为 VDB 条目附加 `evolution_chain`(latest → oldest),结构与 search 一致,
仅在该条记忆确有演化链时出现。
- 默认关闭:展开需按链逐条回查,会拖慢默认列表。
- 只展开当前页,不影响 total 与条目数。
- 注意能看到的仅限「矛盾取代」型演化;「补充精炼」型在写入时是原地更新,
历史版本不落库,故无链可返回。
workspace_id / branch(默认空):
- workspace_id 非空 → 直接走 coding memory 列表路径,按 workspace_id
(+可选 branch)纯过滤列出该 user 的完整经验层(按 updated_at
倒序,无语义排序),返回 {"scene": "productivity", "experiences": [...]}。
- 两者都为空 → 常规 chat 路径(VDB + graph 分桶)。
Returns:
chat 路径:
{
"vdb": {"memories": [...], "total", "limit", "offset"},
"graph": {"nodes": [...], "total", ...} | 省略,
"elapsed_ms": ...,
}
coding 路径:
{
"scene": "productivity",
"experiences": [...],
"total": ...,
"limit": ..., "offset": ...,
"elapsed_ms": ...,
}
"""
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
effective_statuses = self._normalize_statuses(statuses)
list_scene = self._list_scene(scene)
# scene 显式指定 coding/working → 列该类全部经验(workspace_id 可再收窄)
if list_scene in ("coding", "working"):
return await self._list_coding(
user_id=user_id, workspace_id=workspace_id, branch=branch,
limit=limit, offset=offset, request_id=request_id, t0=t0,
statuses=effective_statuses, variant=list_scene,
include_sources=include_sources, exclude_sources=exclude_sources,
)
# workspace_id 非空且未指定 scene → 走 coding memory 列表路径(原有行为)
if workspace_id and list_scene is None:
return await self._list_coding(
user_id=user_id, workspace_id=workspace_id, branch=branch,
limit=limit, offset=offset, request_id=request_id, t0=t0,
statuses=effective_statuses,
include_sources=include_sources, exclude_sources=exclude_sources,
)
effective_layers = self._normalize_layers(layers)
if list_scene == "all":
return await self._list_all_scenes(
user_id=user_id, agent_id=agent_id,
user_ids=user_ids, agent_ids=agent_ids,
session_ids=session_ids,
include_sources=include_sources, exclude_sources=exclude_sources,
layers=effective_layers, statuses=effective_statuses,
workspace_id=workspace_id, branch=branch,
limit=limit, offset=offset, order=order,
request_id=request_id, t0=t0,
)
vdb = await self._list_vdb_bucket(
user_id=user_id, agent_id=agent_id,
user_ids=user_ids, agent_ids=agent_ids,
session_ids=session_ids,
include_sources=include_sources,
exclude_sources=exclude_sources,
limit=limit, offset=offset, order=order,
layers=effective_layers, statuses=effective_statuses,
with_evolution=with_evolution,
)
graph = await self._list_graph_bucket(
user_id=user_id, agent_id=agent_id,
user_ids=user_ids, agent_ids=agent_ids,
session_ids=session_ids,
include_sources=include_sources,
exclude_sources=exclude_sources,
limit=limit, offset=offset, order=order,
layers=effective_layers, statuses=effective_statuses,
)
elapsed = round((time.perf_counter() - t0) * 1000, 2)
logger.info(
f"[list] user={user_ids or user_id} agent={agent_ids or agent_id} "
f"vdb_total={vdb['total']} graph_total={graph['total'] if graph else 'n/a'} "
f"elapsed={elapsed}ms"
)
# pipeline trace(best-effort,受 MEMORY_PIPELINE_TRACE_ENABLED 控制)
_page_items = vdb.get("memories", []) or []
_page_ids = [
(m.get("memory_id") or m.get("id") or "")
for m in _page_items
if isinstance(m, dict)
]
_page_ids = [i for i in _page_ids if i]
_ident = self._backend_identity()
_parsed = {
**_ident,
"user_id": user_id,
"agent_id": agent_id or "",
"limit": limit,
"offset": offset,
"order": order,
"vdb_total": vdb.get("total", 0),
"returned_count": len(_page_items),
}
# op log 的 user_id/agent_id 字段是单值,多值查询范围额外记在 parsed 里,避免审计漏记
if user_ids:
_parsed["user_ids"] = list(user_ids)
if agent_ids:
_parsed["agent_ids"] = list(agent_ids)
if effective_layers:
_parsed["layers"] = [layer.value for layer in effective_layers]
if with_evolution:
_parsed["with_evolution"] = True
if graph is not None:
_parsed["graph_total"] = graph.get("total", 0)
await self._maybe_store_op_log(
step="LIST",
request_id=request_id,
user_id=user_id or (user_ids[0] if user_ids else ""),
agent_id=agent_id or (agent_ids[0] if agent_ids else ""),
parsed=_parsed,
memory_ids=_page_ids or None,
elapsed_ms=elapsed,
)
out: Dict[str, Any] = {"vdb": vdb, "elapsed_ms": elapsed, "request_id": request_id}
if graph is not None:
out["graph"] = graph
return out
# ================================================================
# User Profile / Basic Info(L0)API
# ================================================================
@_ensure_internal_loop
async def async_get_user_profile(
self,
*,
user_id: str,
) -> Dict[str, Any]:
"""Return one complete user profile, including enabled Ultra artifacts."""
from .agent.tools.basic_profile import (
_assemble_full_kv_from_nodes,
_latest_l0_heads_from_nodes,
_render_content,
)
from .models.memory import MemoryLayer
from .pipelines.balanced_catalog import (
attach_balanced_catalog_to_l0,
get_active_balanced_catalog,
)
from .pipelines.specific_supplement import (
attach_specific_supplement_to_l0,
get_active_specific_supplement,
)
normalized_user_id = str(user_id or "").strip()
if not normalized_user_id:
raise ValueError("user_id is required and cannot be empty")
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
# Read all L0 versions once: the latest head supplies identity/timestamps,
# while the complete history is required to reconstruct the full current KV
# because each L0 version may contain only that write's changed fields.
l0_nodes = await self._vector_store.list_by_user(
user_id=normalized_user_id,
layers=[MemoryLayer.L0_BASIC_INFO],
limit=1000,
with_vectors=False,
)
full_kv = _assemble_full_kv_from_nodes(list(l0_nodes))
heads = _latest_l0_heads_from_nodes(list(l0_nodes))
head = heads[0] if heads else None
configured_order = list(self._config.basic_profile.effective_fields().keys())
field_order = configured_order + sorted(
key for key in full_kv if key not in configured_order
)
base_content = _render_content(
full_kv,
field_order=field_order,
)
l0_results: List[Dict[str, Any]] = []
if head is not None:
l0_results.append({
"node_id": head.node_id,
"node": head,
"content": base_content or head.content,
"layer": MemoryLayer.L0_BASIC_INFO.value,
"score": 1.0,
"source": "user_profile",
})
catalog = await get_active_balanced_catalog(
self._graph_store,
user_id=normalized_user_id,
)
l0_results, catalog_info = attach_balanced_catalog_to_l0(
l0_results, catalog,
)
supplement = await get_active_specific_supplement(
self._graph_store,
user_id=normalized_user_id,
)
l0_results, supplement_info = attach_specific_supplement_to_l0(
l0_results, supplement,
)
profile: Optional[Dict[str, Any]] = None
if len(l0_results) == 1:
item = l0_results[0]
node = item.get("node")
content = str(
item.get("content") or getattr(node, "content", "") or ""
).strip()
for suffix_key in (
"_balanced_catalog_suffix",
"_specific_supplement_suffix",
):
suffix = str(item.get(suffix_key) or "").strip()
if suffix:
content = content.rstrip() + ("\n\n" if content else "") + suffix
memory_at = getattr(node, "memory_at", None) if node else None
gmt_created = getattr(node, "gmt_created", None) if node else None
profile = {
"memory_id": str(item.get("node_id") or ""),
"content": content,
"layer": MemoryLayer.L0_BASIC_INFO.value,
"user_id": normalized_user_id,
"basic_info": full_kv,
"tags": list(getattr(node, "tags", None) or []),
"memory_at": (
int(memory_at.timestamp()) if isinstance(memory_at, datetime) else None
),
"gmt_created": (
int(gmt_created.timestamp()) if isinstance(gmt_created, datetime) else None
),
"catalog": catalog_info,
"supplement": supplement_info,
}
elapsed = round((time.perf_counter() - t0) * 1000, 2)
await self._maybe_store_op_log(
step="GET_USER_PROFILE",
request_id=request_id,
user_id=normalized_user_id,
agent_id="",
parsed={
**self._backend_identity(),
"found": profile is not None,
"basic_info_fields": len(full_kv),
"catalog_attached": bool(catalog_info.get("attached")),
"supplement_attached": bool(supplement_info.get("attached")),
},
memory_ids=[profile["memory_id"]] if profile else None,
elapsed_ms=elapsed,
)
return {
"request_id": request_id,
"profile": profile,
"elapsed_ms": elapsed,
}
@_ensure_internal_loop
async def async_get_user_basic_info(
self,
*,
user_id: str = "",
) -> Dict[str, Any]:
"""返回用户的 L0 basic info 记忆(异步)。
与 search / list 一致:当记忆存在演化链(supersede/evolution)时,以链头
(is_latest)为代表返回,并附 `evolution_chain`(latest → oldest)。
Returns:
{
"request_id": str,
"memories": [
{
"memory_id", "content", "layer", "status",
"memory_at", "gmt_created", "tags",
"basic_info_kv": {...}, # L0 结构化 KV(来自 custom)
"evolution_chain": [...], # 仅当有链
},
...
],
"elapsed_ms": float,
}
"""
from .models.memory import MemoryLayer, MemoryStatus
from .pipelines._retrieval.evolution import expand_evolution_chains
from .pipelines._retrieval.user_level_l0 import collapse_user_level_l0_hits
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
# L0 head 是 ACTIVE 状态;链上更早的版本走 evolution 展开时按需拉取。
# basic_info 组装只读 custom.basic_info_kv / content / tags 与链上元信息,
# evolution 展开也不涉及 embedding,就一开始走 with_vectors=False 避免拉向量。
# L0 Basic Info 是 user-level 全局画像;agent_id 仅保留 API 向后兼容。
nodes = await self._vector_store.list_by_user(
user_id=user_id,
status_filter=[MemoryStatus.ACTIVE],
layers=[MemoryLayer.L0_BASIC_INFO],
with_vectors=False,
)
hits = collapse_user_level_l0_hits([
{"node_id": n.node_id, "node": n, "score": 1.0}
for n in nodes
])
expanded = await expand_evolution_chains(self._vector_store, hits)
memories: List[Dict[str, Any]] = []
for exp in expanded:
node = exp.get("node")
if node is None:
continue
_memory_at = (
int(node.memory_at.timestamp())
if isinstance(node.memory_at, datetime) else None
)
_gmt_created = (
int(node.gmt_created.timestamp())
if isinstance(node.gmt_created, datetime) else None
)
item = {
"memory_id": node.node_id,
"content": node.content,
"layer": node.layer.value if node.layer else "",
"status": node.status.value if node.status else "active",
"memory_at": _memory_at,
"gmt_created": _gmt_created,
"tags": node.tags or [],
"basic_info_kv": (node.custom or {}).get("basic_info_kv", {}),
}
if exp.get("evolution_chain"):
item["evolution_chain"] = exp["evolution_chain"]
memories.append(item)
elapsed = round((time.perf_counter() - t0) * 1000, 2)
logger.info(
f"[basic_info] user={user_id} "
f"count={len(memories)} elapsed={elapsed}ms"
)
_parsed = {
**self._backend_identity(),
"user_id": user_id,
"returned_count": len(memories),
}
await self._maybe_store_op_log(
step="GET_BASIC_INFO",
request_id=request_id,
user_id=user_id,
agent_id="",
parsed=_parsed,
memory_ids=[m["memory_id"] for m in memories] or None,
elapsed_ms=elapsed,
)
return {
"request_id": request_id,
"memories": memories,
"elapsed_ms": elapsed,
}
# ================================================================
# Entity Store 构建 / 迁移 API
# ================================================================
def build_memory_graph(
self,
user_id: str,
*,
agent_id: Optional[str] = None,
limit: int = 10000,
clear_first: bool = False,
) -> Dict[str, Any]:
"""为指定 user 的全部 L2_FACT 回填记忆关系图(同步)。
用途:图数据是增量积累的,开启 `MEMORY_ENABLE_GRAPH` 之前写入的记忆不在图里。
这个入口把历史 L2_FACT 投影成图节点 + 建 `TAGGED_WITH` + `semantic_neighbor` 边。
建得出:`TAGGED_WITH`(Topic)+ `semantic_neighbor` + `TEMPORAL_NEXT` 时序脊
(后者需 `MEMORY_GRAPH_TEMPORAL_SPINE=true`)。
⚠️ 回填**建不出** `co_occurs` 和 LLM 标注的 `causes`/`elaborates`/`contrasts`:
前者需要"同一次写入批次"这个信息(历史数据已丢失),后者需要重跑 reconcile。
也就是说回填拿不到信息量最高的那两类边,跨主题的因果关系仍要靠新写入积累。
投影本身是幂等的(节点/标签/边都走 MERGE),重复跑不会叠数据。但它**不会删旧
节点** —— 如果投影规则变过(例如 Topic 隔离键从 `user::agent::session` 收窄成
`user::agent`),旧的 session 粒度 Topic 仍会留在库里与新的并存。这种情况传
`clear_first=True`,重建前先清掉该用户的 l2_fact 投影与孤儿 Topic
(**不动** System 2 的 l6_schema)。
Returns:
{"success", "memories_scanned", "nodes", "tags", "semantic", "errors",
"elapsed_ms", "cleared"}
"""
return self._loop_thread.run(
self.async_build_memory_graph(
user_id=user_id, agent_id=agent_id, limit=limit,
clear_first=clear_first,
)
)
@_ensure_internal_loop
async def async_build_memory_graph(
self,
user_id: str,
*,
agent_id: Optional[str] = None,
limit: int = 10000,
clear_first: bool = False,
) -> Dict[str, Any]:
"""`build_memory_graph` 的异步版本。"""
import time as _t
from .models.memory import MemoryLayer, MemoryStatus
from .pipelines._graph.projector import project_memories_to_graph
t0 = _t.perf_counter()
if self._graph_store is None:
return {
"success": False,
"error": "graph store not enabled (set MEMORY_ENABLE_GRAPH=true for pro mode)",
}
cleared: Dict[str, int] = {}
if clear_first:
# 只清 l2_fact 投影 + 孤儿 Topic;System 2 的 l6_schema 不动
cleared = await self._graph_store.clear_projection(
user_id, agent_id, layer=MemoryLayer.L2_FACT.value,
)
logger.info(f"[graph] cleared before rebuild: {cleared}")
vector_store = self._registry._get_shared_vector_store()
nodes = await vector_store.list_by_user(
user_id=user_id,
agent_id=agent_id,
layers=[MemoryLayer.L2_FACT],
status_filter=[MemoryStatus.ACTIVE],
limit=limit,
with_vectors=True,
)
nodes = list(nodes or [])
# semantic_neighbor 回填:用库里已有的向量算 pairwise 相似度,不调 embedding API
neighbors_by_node: Dict[str, List[Tuple[str, float]]] = {}
gcfg = getattr(self._config, "graph_store", None)
thr = getattr(gcfg, "edge_min_score", 0.55) if gcfg else 0.55
with_emb = [n for n in nodes if getattr(n, "embedding", None)]
for i, a in enumerate(with_emb):
na = sum(x * x for x in a.embedding) ** 0.5
if na == 0:
continue
for b in with_emb[i + 1:]:
nb_ = sum(x * x for x in b.embedding) ** 0.5
if nb_ == 0:
continue
sim = sum(x * y for x, y in zip(a.embedding, b.embedding)) / (na * nb_)
if sim >= thr:
neighbors_by_node.setdefault(a.node_id, []).append((b.node_id, sim))
stats = await project_memories_to_graph(
graph_store=self._graph_store,
embed_service=self._registry._get_shared_embed_service(),
nodes=nodes,
vector_store=vector_store,
neighbors_by_node=neighbors_by_node,
edge_min_score=thr,
# 回填没有"批次"概念,禁掉 co_occurs(否则会把全量记忆两两相连)
co_occurs_max_batch=0,
# 时序脊走 bulk 版:整批就是全部历史,前驱在批内直接排序即可。
# 若误用增量版,会因为"排除批内节点"而一条边都建不出来。
temporal_spine_bulk=bool(
getattr(gcfg, "temporal_spine", False) if gcfg else False
),
hierarchy_topic_write=bool(
getattr(gcfg, "hierarchy_topic_write", False) if gcfg else False
),
)
return {
"success": True,
"memories_scanned": len(nodes),
**stats,
"cleared": cleared,
"elapsed_ms": round((_t.perf_counter() - t0) * 1000, 2),
}
def build_entity_store(
self,
user_id: str,
*,
agent_id: str = "default_agent",
rebuild: bool = False,
) -> Dict[str, Any]:
"""为指定 user 的全部 L2_FACT(含旧数据)抽取 entity 刷入 entity store(同步)。
用于给历史 collection 补建 entity store,供写侧与内部迁移工具使用。
与 mode 无关(pro 也可调)。
Args:
user_id: 用户 ID
agent_id: Agent ID(默认 default_agent)
rebuild: True 时先清空该 user 现有 entity 再重建(默认 False,增量合并)
Returns:
{"success", "memories_scanned", "entities_indexed", "elapsed_ms"}
"""
return self._loop_thread.run(
self.async_build_entity_store(
user_id=user_id, agent_id=agent_id, rebuild=rebuild,
)
)
@_ensure_internal_loop
async def async_build_entity_store(
self,
user_id: str,
*,
agent_id: str = "default_agent",
rebuild: bool = False,
) -> Dict[str, Any]:
"""build_entity_store 的异步实现。"""
from .models.memory import MemoryLayer, MemoryStatus
from .pipelines._retrieval.entity_store import index_memory_entities
t0 = time.perf_counter()
vs = self._vector_store
# 扫描该 user 的全部 ACTIVE L2_FACT
# entity rebuild 只需 node_id/content/user_id/agent_id,下游 index_memory_entities 会对
# entity 名重新 embed,不依赖原 node 的 embedding——走 with_vectors=False 避免拉向量。
try:
nodes = await vs.list_by_user(
user_id=user_id,
agent_id=agent_id,
limit=100000,
status_filter=[MemoryStatus.ACTIVE],
layers=[MemoryLayer.L2_FACT],
with_vectors=False,
)
except Exception as e:
logger.error(f"[build_entity_store] list_by_user failed: {e}")
return {"success": False, "error": str(e), "memories_scanned": 0, "entities_indexed": 0}
# rebuild:先剔除这些 memory 在 entity store 里的关联(best-effort)
if rebuild:
for n in nodes:
try:
await vs.delete_entities_for_memory(
memory_id=n.node_id, user_id=user_id, agent_ids=[agent_id] if agent_id else None,
)
except NotImplementedError:
return {
"success": False,
"error": "entity store not supported by this vector store backend",
"memories_scanned": 0, "entities_indexed": 0,
}
except Exception as e:
logger.debug(f"[build_entity_store] rebuild cleanup failed for {n.node_id}: {e}")
indexed = 0
for n in nodes:
cnt = await index_memory_entities(
vector_store=vs,
embed_service=self._embed_service,
memory_id=n.node_id,
content=n.content or "",
user_id=user_id,
agent_id=agent_id or "default_agent",
)
indexed += cnt
elapsed = round((time.perf_counter() - t0) * 1000, 2)
logger.info(
f"[build_entity_store] user={user_id} agent={agent_id} "
f"scanned={len(nodes)} entities_indexed={indexed} rebuild={rebuild} elapsed={elapsed}ms"
)
return {
"success": True,
"memories_scanned": len(nodes),
"entities_indexed": indexed,
"elapsed_ms": elapsed,
}
# ================================================================
# Clone User API(异步)
# ================================================================
async def _load_clone_source_snapshot(
self,
src_user_id: str,
agent_id: Optional[str],
) -> Dict[str, Any]:
"""Read immutable source state once for a concurrent clone fan-out."""
from .models.memory import MemoryLayer
reads = [self._vector_store.list_by_user(
src_user_id, agent_id=agent_id, allow_raw_layer=True,
)]
include_user_l0 = bool(agent_id)
if include_user_l0:
reads.append(self._vector_store.list_by_user(
src_user_id, layers=[MemoryLayer.L0_BASIC_INFO],
allow_raw_layer=True,
))
graph_export = getattr(self._graph_store, "export_clone_snapshot", None)
if callable(graph_export):
reads.append(graph_export(src_user_id))
clause_export = getattr(
self._vector_store, "export_retrieval_clause_snapshot", None,
)
if callable(clause_export):
reads.append(clause_export(
src_user_id=src_user_id, agent_id=agent_id,
))
values = await asyncio.gather(*reads)
src_nodes = list(values[0] or [])
cursor = 1
if include_user_l0:
seen_node_ids = {node.node_id for node in src_nodes}
src_nodes.extend(
node for node in (values[cursor] or [])
if not str(getattr(node, "agent_id", "") or "").strip()
and node.node_id not in seen_node_ids
)
cursor += 1
graph_snapshot = values[cursor] if callable(graph_export) else None
if callable(graph_export):
cursor += 1
clause_snapshot = values[cursor] if callable(clause_export) else None
return {
"nodes": tuple(src_nodes),
"graph": graph_snapshot,
"retrieval_clauses": clause_snapshot,
}
async def _acquire_clone_source_snapshot(
self,
src_user_id: str,
agent_id: Optional[str],
) -> Tuple[Dict[str, Any], Tuple[Any, Any]]:
"""Single-flight a source snapshot across concurrent destinations.
Entries live only while at least one caller is waiting/using the
snapshot. They are removed immediately after the final waiter exits,
so a later clone observes later source writes rather than stale state.
"""
lock = getattr(self, "_clone_snapshot_lock", None)
if lock is None:
lock = asyncio.Lock()
self._clone_snapshot_lock = lock
entries = getattr(self, "_clone_snapshot_entries", None)
if entries is None:
entries = {}
self._clone_snapshot_entries = entries
key = (src_user_id, agent_id or "")
async with lock:
entry = entries.get(key)
if entry is None:
entry = {
"task": asyncio.create_task(
self._load_clone_source_snapshot(src_user_id, agent_id)
),
"waiters": 0,
}
entries[key] = entry
entry["waiters"] += 1
task = entry["task"]
try:
# One cancelled HTTP clone must not cancel the shared source read
# for its sibling rollout destinations.
return await asyncio.shield(task), (key, entry)
except BaseException:
await self._release_clone_source_snapshot((key, entry))
raise
async def _release_clone_source_snapshot(
self, lease: Tuple[Any, Any],
) -> None:
key, entry = lease
lock = getattr(self, "_clone_snapshot_lock", None)
entries = getattr(self, "_clone_snapshot_entries", None)
if lock is None or entries is None:
return
async with lock:
current = entries.get(key)
if current is entry:
current["waiters"] -= 1
if current["waiters"] <= 0:
entries.pop(key, None)
@_ensure_internal_loop
async def async_clone_user(
self,
src_user_id: str,
dst_user_id: str,
*,
agent_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
克隆用户记忆(异步)。
深拷贝 src 的全部记忆到 dst,embedding 直接复制不重新计算。
dst 必须为空,否则返回错误。操作同步完成,失败时回滚已写入的数据。
Returns:
{"success": True, "request_id": "...", "cloned_count": N, "elapsed_ms": ...}
"""
request_id = str(uuid.uuid4())
set_request_id(request_id)
t0 = time.perf_counter()
if not src_user_id or not dst_user_id:
return {
"success": False,
"request_id": request_id,
"cloned_count": 0,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_message": "src_user_id and dst_user_id are required",
}
if src_user_id == dst_user_id:
return {
"success": False,
"request_id": request_id,
"cloned_count": 0,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_message": "src_user_id and dst_user_id must be different",
}
logger.info(
f"[clone] src={src_user_id} → dst={dst_user_id} agent_id={agent_id}"
)
snapshot_lease = None
try:
from .models.memory import MemoryLayer, MemoryNode
# 1. 检查 dst 是否为空
# 只看长度判空,不需要 embedding;避免为一次存在性探测拉回大 payload。
# allow_raw_layer=True:判空要看"有没有任何数据",含 raw;
# 否则目标只有 raw 时会被误判为空,clone 覆盖上去造成数据混杂。
dst_nodes = await self._vector_store.list_by_user(
dst_user_id, agent_id=agent_id, limit=1, with_vectors=False,
allow_raw_layer=True,
)
# L0_BASIC_INFO is user-scoped and is intentionally stored with an
# empty agent_id. An agent-filtered clone must still treat an
# existing user profile at the destination as data, otherwise it
# can silently overwrite/mix user-level state.
if agent_id and not dst_nodes:
dst_user_nodes = await self._vector_store.list_by_user(
dst_user_id, layers=[MemoryLayer.L0_BASIC_INFO],
with_vectors=False, allow_raw_layer=True,
)
dst_nodes = [
node for node in dst_user_nodes
if not str(getattr(node, "agent_id", "") or "").strip()
][:1]
if dst_nodes:
return {
"success": False,
"request_id": request_id,
"cloned_count": 0,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_message": (
f"dst_user_id '{dst_user_id}' already has memories. "
"Clone requires an empty destination."
),
}
# 2. 列出 src 的全量记忆(含 embedding)。同一 winner 同时
# fan-out 到多个 rollout 时共享一次在途源快照,避免重复全库 scroll。
# allow_raw_layer=True:clone 语义是"完整复制这个用户的记忆",
# 含原始对话层——目标用户的提取管线同样要靠 raw 拼对话历史,
# 漏掉 raw 会让克隆出的用户丢失全部历史上下文。
source_snapshot, snapshot_lease = await self._acquire_clone_source_snapshot(
src_user_id, agent_id,
)
src_nodes = list(source_snapshot["nodes"])
if not src_nodes:
return {
"success": True,
"request_id": request_id,
"cloned_count": 0,
"by_layer": {},
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_message": None,
}
logger.info(f"[clone] found {len(src_nodes)} nodes to clone")
# 3. 深拷贝:第一遍——生成新 node_id,建完整映射表
id_map: Dict[str, str] = {} # old_node_id → new_node_id
for src_node in src_nodes:
id_map[src_node.node_id] = str(uuid.uuid4())
# 第二遍——用完整的 id_map 做深拷贝 + 指针重映射
from .utils.clone_references import remap_memory_references
new_nodes: List = []
for src_node in src_nodes:
new_id = id_map[src_node.node_id]
# 使用 to_dict + from_dict 做深拷贝
source_dict = src_node.to_dict()
node_dict = remap_memory_references(source_dict, id_map)
node_dict["node_id"] = new_id
node_dict["user_id"] = dst_user_id
# 重置检索元数据
node_dict["access_count"] = 0
node_dict["last_accessed_at"] = None
# 映射演化链指针(supersedes / superseded_by)
# 指向映射表外的 ID(不在本次 clone 范围内)则清空
if source_dict.get("supersedes"):
node_dict["supersedes"] = [
id_map[sid] for sid in source_dict["supersedes"]
if sid in id_map
]
if source_dict.get("superseded_by"):
node_dict["superseded_by"] = [
id_map[sid] for sid in source_dict["superseded_by"]
if sid in id_map
]
# 映射 evidence_chain 中的 ID
if source_dict.get("evidence_chain"):
node_dict["evidence_chain"] = [
id_map[eid] for eid in source_dict["evidence_chain"]
if eid in id_map
]
new_node = MemoryNode.from_dict(node_dict)
# 保留原始 embedding(content 不变,无需重新计算)
new_node.embedding = src_node.embedding
# 保留原始时间(不要被 __post_init__ 覆盖)
new_node.memory_at = src_node.memory_at
new_node.gmt_created = src_node.gmt_created
new_node.valid_from = src_node.valid_from
new_nodes.append(new_node)
# 5. 批量写入向量库
written_ids = await self._vector_store.upsert_batch(new_nodes)
logger.info(f"[clone] upsert_batch: {len(written_ids)} nodes written")
written_id_set = set(written_ids)
if written_id_set != {node.node_id for node in new_nodes}:
raise RuntimeError("clone incomplete: not all destination nodes were written")
clone_clauses = getattr(self._vector_store, "clone_retrieval_clauses", None)
import_clauses = getattr(
self._vector_store, "import_retrieval_clause_snapshot", None,
)
clause_snapshot = source_snapshot.get("retrieval_clauses")
if clause_snapshot is not None and callable(import_clauses):
await import_clauses(
clause_snapshot,
dst_user_id=dst_user_id,
id_map=id_map,
)
elif callable(clone_clauses):
await clone_clauses(
src_user_id=src_user_id, dst_user_id=dst_user_id,
id_map=id_map, agent_id=agent_id,
)
by_layer: Dict[str, int] = {}
for node in new_nodes:
if node.node_id not in written_id_set:
continue
layer = node.layer.value if hasattr(node.layer, "value") else str(node.layer)
by_layer[layer] = by_layer.get(layer, 0) + 1
# 6. 图数据库克隆(如启用)
# 正确流程:从源 Graph 读节点 + 边,用 id_map 重映射后写入目标 Graph
if self._graph_store is not None:
graph_snapshot = source_snapshot.get("graph")
graph_import = getattr(
self._graph_store, "import_clone_snapshot", None,
)
if graph_snapshot is not None and callable(graph_import):
try:
await graph_import(
graph_snapshot,
dst_user_id=dst_user_id,
id_map=id_map,
embed_service=self._embed_service,
)
except Exception as graph_error:
logger.warning(
"[clone] native Graph snapshot import failed; "
"falling back to legacy clone: %s", graph_error,
)
await self._clone_graph(
src_user_id=src_user_id,
dst_user_id=dst_user_id,
agent_id=agent_id,
id_map=id_map,
)
else:
await self._clone_graph(
src_user_id=src_user_id,
dst_user_id=dst_user_id,
agent_id=agent_id,
id_map=id_map,
)
# 7. 复制 memory_operations 记录(src → dst,用 id_map 重映射)
if self._cache:
try:
await self._clone_memory_operations(
src_user_id=src_user_id,
dst_user_id=dst_user_id,
clone_request_id=request_id,
id_map=id_map,
)
except Exception as ops_err:
logger.warning(f"[clone] clone memory_operations failed (non-fatal): {ops_err}")
logger.info(
f"[clone] done: {len(written_ids)} nodes cloned "
f"from {src_user_id} to {dst_user_id}"
)
return {
"success": True,
"request_id": request_id,
"cloned_count": len(written_ids),
"by_layer": by_layer,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_message": None,
}
except Exception as e:
logger.error(f"[clone] failed: {e}", exc_info=True)
# 回滚:删除已写入 dst 的数据
try:
await self._vector_store.delete_by_metadata(user_id=dst_user_id)
delete_clauses = getattr(self._vector_store, "delete_retrieval_clauses_by_metadata", None)
if callable(delete_clauses):
await delete_clauses(user_id=dst_user_id)
await self._purge_graph_data(dst_user_id)
logger.info(f"[clone] rollback: cleaned dst_user_id={dst_user_id}")
except Exception as rollback_err:
logger.warning(f"[clone] rollback failed: {rollback_err}")
return {
"success": False,
"request_id": request_id,
"cloned_count": 0,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"error_message": str(e),
}
finally:
if snapshot_lease is not None:
await self._release_clone_source_snapshot(snapshot_lease)
# ================================================================
# Clone Graph Helper
# ================================================================
async def _clone_graph(
self,
src_user_id: str,
dst_user_id: str,
agent_id: Optional[str],
id_map: Dict[str, str],
) -> None:
"""
从源用户的 Graph 深拷贝到目标用户。
流程:
1. 读源 Graph 的所有 Memory 节点(按 isolation_key)
2. 用 id_map 重映射节点 ID + user_id + isolation_key,写入目标 Graph
3. 复制 VdbRef 影子节点(ID 重映射)+ DERIVED_FROM 边
4. 复制 Memory→Memory 关系边(RELATED_TO)
5. 复制 TAGGED_WITH 边(Topic tag)
"""
from .models.memory import MemoryNode
gs = self._graph_store
if gs is None:
return
# 构建源/目标 isolation_key
# 源用户可能有多个 session,用 get_all_nodes 按 user_id 前缀匹配
# 但 get_all_nodes 需要精确 isolation_key,所以遍历 id_map 里的源节点找 isolation_key
try:
# Step 1: 读源 Graph 的所有 Memory 节点
# 用 Cypher 按 user_id 查(不依赖 isolation_key,更通用)
src_graph_nodes = []
if hasattr(gs, '_execute') and hasattr(gs, '_available'):
# Kuzu
if not gs._available:
return
result = gs._execute(
"MATCH (m:Memory) WHERE m.user_id = $uid RETURN m;",
{"uid": src_user_id},
)
while result is not None and result.has_next():
row = result.get_next()
src_graph_nodes.append(gs._row_to_memory_node(row[0]))
elif hasattr(gs, '_run'):
# Neo4j — 同时取 embedding / beh_embedding 以便 clone 时一并复制
rows = await gs._run(
"MATCH (m:Memory) WHERE m.user_id = $uid "
"RETURN m, m.embedding AS _emb, m.beh_embedding AS _beh_emb",
{"uid": src_user_id},
)
for r in rows:
node = gs._record_to_memory_node(r["m"])
# 把 embedding 暂存到 node 上,供后续 upsert 使用
if r.get("_emb") is not None:
node._graph_embedding = list(r["_emb"])
if r.get("_beh_emb") is not None:
node._graph_beh_embedding = list(r["_beh_emb"])
src_graph_nodes.append(node)
if not src_graph_nodes:
logger.info("[clone] no Graph nodes to clone")
return
logger.info(f"[clone] Graph: found {len(src_graph_nodes)} source nodes")
# 记住每个源节点的 old_id,确保 id_map 覆盖
graph_old_ids = [] # 与 src_graph_nodes 一一对应
for src_node in src_graph_nodes:
old_nid = src_node.node_id
graph_old_ids.append(old_nid)
if old_nid not in id_map:
id_map[old_nid] = str(uuid.uuid4())
# Step 2: 写入目标 Graph Memory 节点(深拷贝 + ID 重映射)
from .models.memory import MemoryNode as _MN
for i, src_node in enumerate(src_graph_nodes):
old_nid = graph_old_ids[i]
new_id = id_map[old_nid]
# 深拷贝,不修改源 node
from .utils.clone_references import remap_memory_references
d = remap_memory_references(src_node.to_dict(), id_map)
d["node_id"] = new_id
d["user_id"] = dst_user_id
dst_node = _MN.from_dict(d)
# 携带源节点的 embedding(to_dict 不含 embedding,需手动传递)
src_emb = getattr(src_node, '_graph_embedding', None)
src_beh = getattr(src_node, '_graph_beh_embedding', None)
if src_emb:
dst_node._graph_embedding = src_emb
try:
await gs.upsert_memory_node(dst_node)
# upsert 后再补 beh_embedding(upsert 只写 embedding,不写 beh)
if src_beh:
await gs.update_embedding(new_id, beh_embedding=src_beh)
except Exception as e:
logger.warning(f"[clone] Graph node upsert failed {new_id}: {e}")
# Step 3: 复制 VdbRef + DERIVED_FROM 边
for i, old_nid in enumerate(graph_old_ids):
new_id = id_map[old_nid]
try:
evidence = await gs.get_evidence_vdbrefs(old_nid)
except Exception:
evidence = []
for ev in evidence:
old_vdbref_id = ev["node_id"]
new_vdbref_id = id_map.get(old_vdbref_id, old_vdbref_id)
try:
await gs.ensure_vdbref(new_vdbref_id, ev.get("layer", ""))
await gs.add_derived_from(new_id, new_vdbref_id)
except Exception as e:
logger.debug(f"[clone] DERIVED_FROM edge failed: {e}")
# Step 4: 复制 Memory→Memory 关系边。
#
# RELATED_TO 的 relation_type / weight 是读侧图重排的核心语义,
# 不能只复制 Neo4j/Kuzu 的关系表类型。旧实现传空 properties,导致
# semantic_neighbor/co_occurs/typed edges 全部退化成默认 related,
# clone bank 与 seed bank 的检索行为因此不再等价。
seen_relation_edges = set()
for i, old_nid in enumerate(graph_old_ids):
try:
if hasattr(gs, '_execute'):
result = gs._execute(
"""
MATCH (a:Memory {node_id: $nid})-[r:RELATED_TO]->(b:Memory)
RETURN b.node_id, r.relation_type, r.weight;
""",
{"nid": old_nid},
)
edges = []
while result is not None and result.has_next():
row = result.get_next()
edges.append({
"target": row[0],
"edge_type": "RELATED_TO",
"properties": {
"relation_type": row[1] or "related",
"weight": float(row[2]) if row[2] is not None else 1.0,
},
})
elif hasattr(gs, '_run'):
rows = await gs._run(
"""
MATCH (a:Memory {node_id: $nid})-[r:RELATED_TO]->(b:Memory)
RETURN b.node_id AS target, type(r) AS edge_type,
properties(r) AS properties
""",
{"nid": old_nid},
)
edges = [{
"target": r["target"],
"edge_type": r["edge_type"],
"properties": {
"relation_type": (r.get("properties") or {}).get(
"relation_type", "related"
),
"weight": (r.get("properties") or {}).get("weight", 1.0),
},
} for r in rows]
else:
edges = []
except Exception:
edges = []
for edge in edges:
et = edge["edge_type"]
if et != "RELATED_TO":
continue
new_src = id_map.get(old_nid)
new_tgt = id_map.get(edge["target"])
if new_src and new_tgt:
properties = dict(edge.get("properties") or {})
relation_type = properties.get("relation_type") or "related"
edge_key = (
frozenset((old_nid, edge["target"])),
relation_type,
)
if edge_key in seen_relation_edges:
continue
seen_relation_edges.add(edge_key)
try:
await gs.add_edge(new_src, new_tgt, et, properties)
except Exception as e:
logger.debug(f"[clone] edge {et} failed: {e}")
# Step 4b: 复制有向时序脊。这里只读 source 端,避免把同一有向边
# 从两个端点各重放一次;topic_id 参与时间语义,必须保留。
for old_nid in graph_old_ids:
try:
if hasattr(gs, '_execute'):
result = gs._execute(
"""
MATCH (a:Memory {node_id: $nid})-[r:TEMPORAL_NEXT]->(b:Memory)
RETURN b.node_id, r.topic_id;
""",
{"nid": old_nid},
)
temporal_edges = []
while result is not None and result.has_next():
row = result.get_next()
temporal_edges.append({"target": row[0], "topic_id": row[1] or ""})
elif hasattr(gs, '_run'):
temporal_edges = await gs._run(
"""
MATCH (a:Memory {node_id: $nid})-[r:TEMPORAL_NEXT]->(b:Memory)
RETURN b.node_id AS target, r.topic_id AS topic_id
""",
{"nid": old_nid},
)
else:
temporal_edges = []
except Exception:
temporal_edges = []
for edge in temporal_edges:
new_src = id_map.get(old_nid)
new_tgt = id_map.get(edge["target"])
if new_src and new_tgt:
try:
await gs.add_temporal_edge(
new_src, new_tgt, edge.get("topic_id") or ""
)
except Exception as e:
logger.debug(f"[clone] TEMPORAL_NEXT edge failed: {e}")
# Step 4c: 复制 System 2 的跨抽象有向边。
for old_nid in graph_old_ids:
try:
if hasattr(gs, '_execute'):
result = gs._execute(
"""
MATCH (a:Memory {node_id: $nid})-[:CROSS_ABSTRACTS_TO]->(b:Memory)
RETURN b.node_id;
""",
{"nid": old_nid},
)
cross_targets = []
while result is not None and result.has_next():
cross_targets.append(result.get_next()[0])
elif hasattr(gs, '_run'):
rows = await gs._run(
"""
MATCH (a:Memory {node_id: $nid})-[:CROSS_ABSTRACTS_TO]->(b:Memory)
RETURN b.node_id AS target
""",
{"nid": old_nid},
)
cross_targets = [row["target"] for row in rows]
else:
cross_targets = []
except Exception:
cross_targets = []
for old_tgt in cross_targets:
new_src = id_map.get(old_nid)
new_tgt = id_map.get(old_tgt)
if new_src and new_tgt:
try:
await gs.add_cross_abstracts_to(new_src, new_tgt)
except Exception as e:
logger.debug(f"[clone] CROSS_ABSTRACTS_TO edge failed: {e}")
# Step 5: rebuild Topic and hierarchy membership in the destination
# scope. Hierarchy node ids include user scope + subject + full path,
# so copying source edges/ids would leak source-user identity.
for i, old_nid in enumerate(graph_old_ids):
new_id = id_map[old_nid]
src_node = src_graph_nodes[i]
dst_ik = MemoryNode.build_isolation_key(
dst_user_id,
src_node.agent_id or "default_agent",
src_node.session_id or "default_session",
)
try:
if hasattr(gs, '_execute'):
result = gs._execute(
"MATCH (m:Memory {node_id: $nid})-[:TAGGED_WITH]->(t:Topic) RETURN t.name;",
{"nid": old_nid},
)
tag_rows = []
while result is not None and result.has_next():
tag_rows.append({"name": result.get_next()[0], "embedding": None})
elif hasattr(gs, '_run'):
tag_rows = await gs._run(
"MATCH (m:Memory {node_id: $nid})-[:TAGGED_WITH]->(t:Topic) "
"RETURN t.name AS name, t.embedding AS embedding",
{"nid": old_nid},
)
else:
tag_rows = []
except Exception:
tag_rows = []
for tr in tag_rows:
tag_name = tr["name"]
tag_emb = tr.get("embedding")
try:
await gs.add_topic_tag(new_id, tag_name, dst_ik)
# 把源 Topic 的 embedding 写到目标 Topic(add_topic_tag 按 name
# 精确匹配时不设 embedding,需要补写)
if tag_emb is not None and hasattr(gs, '_run_write'):
await gs._run_write(
"MATCH (t:Topic {isolation_key: $ik, name: $name}) "
"WHERE t.embedding IS NULL "
"SET t.embedding = $emb",
{"ik": dst_ik, "name": tag_name, "emb": list(tag_emb)},
)
except Exception as e:
logger.debug(f"[clone] TAGGED_WITH failed: {e}")
custom = getattr(src_node, "custom", None) or {}
from .structure import topic_assignments_from_custom
assignments = topic_assignments_from_custom(custom)
if assignments and hasattr(gs, "add_topic_path"):
dst_topic_scope = MemoryNode.build_topic_scope_key(
dst_user_id, src_node.agent_id or "default_agent",
)
for assignment in assignments:
try:
try:
await gs.add_topic_path(
new_id, assignment["path"], dst_topic_scope,
assignment["subject"],
node_types=assignment.get("node_types"),
role=assignment.get("role") or "participant",
embed_service=getattr(self, "_embed_service", None),
)
except TypeError:
await gs.add_topic_path(
new_id, assignment["path"], dst_topic_scope,
assignment["subject"],
embed_service=getattr(self, "_embed_service", None),
)
except Exception as e:
logger.debug(f"[clone] hierarchy topic failed: {e}")
# 正文 NER 是 normal recall 的独立加分通道。clone 后必须重建
# MENTIONS,否则同一份 bank 仅因 user_id 改名就失去 entity recall。
try:
if hasattr(gs, '_execute'):
result = gs._execute(
"""
MATCH (m:Memory {node_id: $nid})-[:MENTIONS]->(e:Entity)
RETURN e.name, e.label;
""",
{"nid": old_nid},
)
entity_rows = []
while result is not None and result.has_next():
row = result.get_next()
entity_rows.append({"name": row[0], "label": row[1]})
elif hasattr(gs, '_run'):
entity_rows = await gs._run(
"""
MATCH (m:Memory {node_id: $nid})-[:MENTIONS]->(e:Entity)
RETURN e.name AS name, e.label AS label
""",
{"nid": old_nid},
)
else:
entity_rows = []
except Exception:
entity_rows = []
for entity in entity_rows:
try:
await gs.add_entity_mention(
new_id, entity.get("name") or "",
entity.get("label") or "", dst_ik,
)
except Exception as e:
logger.debug(f"[clone] MENTIONS failed: {e}")
logger.info(f"[clone] Graph: cloned {len(src_graph_nodes)} nodes with edges")
except Exception as e:
logger.warning(f"[clone] Graph clone failed (non-fatal): {e}")
async def _clone_memory_operations(
self,
src_user_id: str,
dst_user_id: str,
clone_request_id: str,
id_map: Dict[str, str],
) -> None:
"""
复制源用户的 memory_operations 到目标用户。
- user_id 替换为 dst_user_id
- memory_id / old_memory_id 按 id_map 重映射(找不到映射的保留原值)
- supersedes 列表中的 ID 也做重映射
- request_id 保留源的(保持 per_request 分组语义)
- 额外追加一条 CLONE op 记录(标记 clone 来源)
"""
import json as _json
# 1. 查出 src 的所有 ops
src_ops = await self._cache.get_memory_operations(user_id=src_user_id, limit=50000)
if not src_ops:
logger.info("[clone] no memory_operations to clone")
return
# 2. 逐条写入 dst(ID 重映射)
cloned_count = 0
for op in src_ops:
# 重映射 memory_id
src_mem_id = op.get("memory_id", "")
dst_mem_id = id_map.get(src_mem_id, src_mem_id)
# 重映射 old_memory_id
src_old_id = op.get("old_memory_id") or ""
dst_old_id = id_map.get(src_old_id, src_old_id) if src_old_id else None
# 重映射 supersedes 列表
raw_supersedes = op.get("supersedes", [])
if isinstance(raw_supersedes, str):
try:
raw_supersedes = _json.loads(raw_supersedes)
except Exception:
raw_supersedes = []
dst_supersedes = [id_map.get(sid, sid) for sid in (raw_supersedes or [])]
await self._cache.store_memory_operation(
request_id=op.get("request_id", clone_request_id),
user_id=dst_user_id,
agent_id=op.get("agent_id", ""),
op=op.get("op", "ADD"),
memory_id=dst_mem_id,
content=op.get("content", ""),
layer=op.get("layer", ""),
old_memory_id=dst_old_id,
reason=op.get("reason", ""),
supersedes=dst_supersedes,
)
cloned_count += 1
# 3. 追加一条 CLONE 元记录(标记 clone 来源,方便回溯)
await self._cache.store_memory_operation(
request_id=clone_request_id,
user_id=dst_user_id,
agent_id="",
op="CLONE",
memory_id="",
content=_json.dumps({
"src_user_id": src_user_id,
"dst_user_id": dst_user_id,
"ops_cloned": cloned_count,
}, ensure_ascii=False),
layer="",
reason=f"cloned from {src_user_id}",
)
logger.info(f"[clone] memory_operations: cloned {cloned_count} ops from {src_user_id} → {dst_user_id}")
# ================================================================
# History API(异步)
# ================================================================
@_ensure_internal_loop
async def async_get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
"""Read a durable write result without scanning memory records."""
if not request_id:
raise ValueError("request_id is required")
return await self._cache.get_write_outcome(request_id)
@_ensure_internal_loop
async def async_history(self, memory_id: str) -> List[Dict[str, Any]]:
"""查询某条记忆的变更历史(异步)"""
if self._history_store is None:
return []
return await self._history_store.get_history(memory_id)
@_ensure_internal_loop
async def async_get_recent_history(
self,
user_id: str = "",
agent_ids: Optional[List[str]] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""
查询最近的操作历史(异步)。
agent_ids 为空时,查询该用户所有 agent 的历史。
"""
if self._history_store is None:
return []
uid = user_id
effective_agent_ids = agent_ids if agent_ids is not None else []
if not effective_agent_ids:
# 查所有 agent 的历史:isolation_key 前缀匹配。
# 不能退回 "u:default" —— add() 的默认 agent 是 "default_agent",
# 默认用法下会查空。
return await self._history_store.get_recent_for_user(uid, limit=limit)
else:
# 查指定 agent 的历史
all_results = []
for aid in effective_agent_ids:
isolation_key = self._build_isolation_key(uid, aid)
results = await self._history_store.get_recent(isolation_key, limit=limit)
all_results.extend(results)
# 按时间倒序排列,取 top limit
all_results.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return all_results[:limit]
# ================================================================
# Context manager
# ================================================================
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
async def __aenter__(self):
return self
async def __aexit__(self, *args):
try:
await self._close_async()
finally:
self._stop_owned_loops()