Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df371bb10 | ||
|
|
f38c370fbd | ||
|
|
e87d6305be | ||
|
|
7a78fca807 | ||
|
|
1520b6de1f | ||
|
|
3742b3e453 | ||
|
|
98bc52b9f4 | ||
|
|
30fea9394f | ||
|
|
e966e913b6 | ||
|
|
ba6248b90b | ||
|
|
6cad02bc82 | ||
|
|
fdc57822b5 | ||
|
|
b20331aa39 | ||
|
|
104fbcce09 | ||
|
|
302fd30af1 | ||
|
|
3772a69c6b |
No files matched your search
@@ -40,6 +40,7 @@ MEMORY_EMBEDDING_DIMS=1024
|
||||
# MEMORY_EMBEDDER_TIMEOUT=15
|
||||
# MEMORY_EMBEDDER_MAX_RETRIES=2
|
||||
# MEMORY_EMBEDDER_RETRY_DELAY=0.3
|
||||
# MEMORY_EMBEDDER_BATCH_SIZE=64
|
||||
# MEMORY_EMBEDDER_EXTRA_HEADERS={}
|
||||
# MEMORY_EMBEDDER_EXTRA_BODY={}
|
||||
|
||||
|
||||
@@ -4,6 +4,75 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## Hy Memory 2.0.10 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.10`](https://pypi.org/project/hy-memory/2.0.10/).
|
||||
- New `hy_memory.reembed` module for switching the embedding
|
||||
model. Writers must be paused for the whole run. Nothing is derived from
|
||||
the provider or endpoint: source and target are named explicitly, and the
|
||||
identity of a space is the model name plus the width (`space_identity`).
|
||||
Only Chroma and Kuzu are supported.
|
||||
- `vector_space_names(config, base_name=None)` returns every physical
|
||||
collection a client opens for the six vector roles: memory, entities,
|
||||
coding keys and working facts, each in the working and coding variants.
|
||||
- `reembed_vector_space(source, target, embedding=...)` pages every raw
|
||||
source collection, covering all users and all layers including L1_RAW. It
|
||||
embeds the text the write path embedded (`reembed_text`: the canonical
|
||||
state's `state_retrieval_text`, a schema's `retrieval_text`, otherwise the
|
||||
document). It then writes each record into the target with the same id,
|
||||
document and metadata. Each batch's widths are checked before anything is
|
||||
written. A cancel stops at a batch boundary (`ReembedCancelled`). A rerun
|
||||
skips the ids the target already holds, so it resumes, and a merge never
|
||||
overwrites the target. The source is never written.
|
||||
- `plan_reembed`, `verify_reembed` and `drop_vector_space` complete the
|
||||
vector side. `verify_reembed` checks ids, metadata, per-user counts and
|
||||
the width, and runs a self-recall sample. `drop_vector_space` refuses
|
||||
collections that a live client in this process holds open.
|
||||
- `scan_vector_spaces(persist_directory)` is read-only. It lists every
|
||||
space on disk with its roles, counts, stored width, per-user counts and
|
||||
the models recorded in metadata. `diff_spaces(source, target)` returns the
|
||||
ids that the source holds and the target lacks, whatever their status.
|
||||
- `reembed_graph(source_path, target_path, embedding=..., target_dims=...)`
|
||||
builds a new Kuzu graph at the new width. It copies every node and edge
|
||||
and recomputes `Memory.embedding`, `Memory.beh_embedding`, and the Topic
|
||||
and HierarchyTopic embeddings, then rebuilds the HNSW index. The source
|
||||
graph is opened read-only and kept for rollback. A run that fails or is
|
||||
cancelled leaves no target behind, and a rerun starts over.
|
||||
`verify_graph_reembed` and `drop_graph` complete the graph side.
|
||||
- `EmbedService.embed_batch` now splits large batches into
|
||||
requests of `embedder.batch_size` texts each (`MEMORY_EMBEDDER_BATCH_SIZE`,
|
||||
default 64); before, it sent them all in one request. On a 429 it waits for
|
||||
the `Retry-After` time plus jitter, capped at 60 s. It takes a per-call
|
||||
`max_retries`. A returned vector whose width differs from the configured
|
||||
`embedding_dims` now raises `EmbeddingDimensionMismatchError` and is not
|
||||
retried.
|
||||
- Raw conversations are stored already trimmed. MemHub now keeps every raw
|
||||
forever and lite mode stores nothing else, so tool output and long pasted
|
||||
logs no longer stay in full. One rule (`hy_memory/utils/raw_trim.py`) is
|
||||
applied to each message when the raw is written:
|
||||
- `user` and `system` turns are kept in full;
|
||||
- `assistant` turns and other speakers (`[John]:`) longer than 500
|
||||
characters, after stripping, are cut to the first 500 plus `...`. This is
|
||||
the limit history injection used before;
|
||||
- `tool` turns keep their head and tail, and the middle becomes
|
||||
`[...N bytes omitted by truncate...]`, with the coding limit
|
||||
`coding.tool_result_max_bytes` (`MEMORY_CODING_TOOL_RESULT_MAX_BYTES`,
|
||||
default 2048).
|
||||
|
||||
The rule is idempotent. It covers chat `L1_RAW` in lite, pro and ultra (lite
|
||||
keeps tool turns, because it has no coding route) and the `memory_history`
|
||||
ADD record. The coding/working `coding_trace` was already trimmed when it
|
||||
was written. Its tool and assistant truncations are now idempotent, so
|
||||
reading the history back no longer rewrites the omitted-count markers. The
|
||||
text embedded for `L1_RAW` is the trimmed text. New raws carry
|
||||
`custom.raw_trimmed=true`.
|
||||
- Extractor history ("last k messages") now joins the stored raws directly
|
||||
and does not trim them again. Raws written before this change have no
|
||||
`raw_trimmed` flag, so they are still trimmed with the same rule when they
|
||||
are read. The extractor still receives the current batch in full (tool
|
||||
turns are still removed on the chat route).
|
||||
|
||||
## MemHub 1.0.5 — 2026-10-28
|
||||
|
||||
Build 36, on `hy-memory==2.0.8`. Linux x64/arm64 Runtime and macOS arm64
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
| `MEMORY_EMBEDDER_TIMEOUT` | `15` | 单次超时,秒 |
|
||||
| `MEMORY_EMBEDDER_MAX_RETRIES` | `2` | 最大重试次数 |
|
||||
| `MEMORY_EMBEDDER_RETRY_DELAY` | `0.3` | 重试间隔,秒 |
|
||||
| `MEMORY_EMBEDDER_BATCH_SIZE` | `64` | `embed_batch` 单次请求最多发送的文本条数,超出按此切块 |
|
||||
| `MEMORY_EMBEDDER_EXTRA_HEADERS` | — | JSON object,附加 HTTP headers |
|
||||
| `MEMORY_EMBEDDER_EXTRA_BODY` | — | JSON object,附加请求 body |
|
||||
|
||||
|
||||
@@ -55,6 +55,33 @@ except Exception:
|
||||
from .client import HyMemoryClient
|
||||
from .inspector import MemoryInspector
|
||||
from .runtime import SharedRuntime
|
||||
from .reembed import (
|
||||
EmbeddingSpaceIdentity,
|
||||
GraphReembedPlan,
|
||||
GraphReembedProgress,
|
||||
GraphReembedReport,
|
||||
GraphVerification,
|
||||
ReembedCancelled,
|
||||
ReembedPlan,
|
||||
ReembedProgress,
|
||||
ReembedReport,
|
||||
ReembedVerification,
|
||||
ScannedSpace,
|
||||
SpaceDiff,
|
||||
VectorSpace,
|
||||
diff_spaces,
|
||||
drop_graph,
|
||||
drop_vector_space,
|
||||
plan_reembed,
|
||||
reembed_graph,
|
||||
reembed_text,
|
||||
reembed_vector_space,
|
||||
scan_vector_spaces,
|
||||
space_identity,
|
||||
vector_space_names,
|
||||
verify_graph_reembed,
|
||||
verify_reembed,
|
||||
)
|
||||
from .snapshot import (
|
||||
DataPlaneRestore,
|
||||
DataPlaneSnapshot,
|
||||
@@ -129,6 +156,32 @@ __all__ = [
|
||||
"DataPlaneSnapshotRejected",
|
||||
"restore_local_data_plane",
|
||||
"snapshot_local_data_plane",
|
||||
# 换 embedding 模型:把向量库和图重新 embed 进新的 embedding 空间
|
||||
"EmbeddingSpaceIdentity",
|
||||
"GraphReembedPlan",
|
||||
"GraphReembedProgress",
|
||||
"GraphReembedReport",
|
||||
"GraphVerification",
|
||||
"ReembedCancelled",
|
||||
"ReembedPlan",
|
||||
"ReembedProgress",
|
||||
"ReembedReport",
|
||||
"ReembedVerification",
|
||||
"ScannedSpace",
|
||||
"SpaceDiff",
|
||||
"VectorSpace",
|
||||
"diff_spaces",
|
||||
"drop_graph",
|
||||
"drop_vector_space",
|
||||
"plan_reembed",
|
||||
"reembed_graph",
|
||||
"reembed_text",
|
||||
"reembed_vector_space",
|
||||
"scan_vector_spaces",
|
||||
"space_identity",
|
||||
"vector_space_names",
|
||||
"verify_graph_reembed",
|
||||
"verify_reembed",
|
||||
# 配置
|
||||
"MemoryConfig",
|
||||
# 公共扩展协议 / 精确读取类型
|
||||
|
||||
+11
-2
@@ -3089,9 +3089,18 @@ class HyMemoryClient:
|
||||
if self._history_store is not None:
|
||||
try:
|
||||
isolation_key = self._build_isolation_key(uid, agent_id)
|
||||
# 从 content 或 messages 中提取用于记录的文本
|
||||
# 从 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 chat_messages
|
||||
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,
|
||||
|
||||
@@ -11,6 +11,7 @@ Coding Memory 预处理工具(规则,零 LLM)
|
||||
使用方式与 scene 路由见 docs/scene_coding_usage.md。
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..pipelines.base import ChatMessage
|
||||
@@ -63,6 +64,13 @@ DEFAULT_MAX_TOOL_RESULT_BYTES = 2048
|
||||
DEFAULT_HEAD_BYTES = 1024
|
||||
DEFAULT_TAIL_BYTES = 512
|
||||
|
||||
# 本模块自己产出的省略标记。截断结果会再次经过截断(落库前一次、读历史时又一次),
|
||||
# 认出自己的标记才能保证幂等:已截断的文本再截一次原样返回,而不是把省略计数改写。
|
||||
_TOOL_OMIT_MARKER_RE = re.compile(r"\n\n\[\.\.\.\d+ bytes omitted by truncate\.\.\.\]\n\n")
|
||||
# 标记本身的字节数上限(数字位数有限),用于判断「已截断且只超出一个标记的长度」。
|
||||
_TOOL_OMIT_MARKER_MAX_BYTES = 128
|
||||
_ASSISTANT_OMIT_MARKER_RE = re.compile(r" \[\.\.\.\d+ chars omitted\.\.\.\] ")
|
||||
|
||||
|
||||
def truncate_tool_result_text(
|
||||
text: str,
|
||||
@@ -81,6 +89,12 @@ def truncate_tool_result_text(
|
||||
raw = text.encode("utf-8")
|
||||
if len(raw) <= max_bytes:
|
||||
return text
|
||||
# 已截断过(max_bytes 很小时标记本身会让结果略超上限):原样返回,保证幂等。
|
||||
if (
|
||||
len(raw) <= max_bytes + _TOOL_OMIT_MARKER_MAX_BYTES
|
||||
and _TOOL_OMIT_MARKER_RE.search(text)
|
||||
):
|
||||
return text
|
||||
# 自定义 max_bytes 可能小于默认 head+tail(1024+512)。按原比例缩放内容窗口,
|
||||
# 给省略标记预留空间,不能因为窗口和过大而把超限原文直接返回。
|
||||
marker_budget = min(96, max(24, max_bytes // 4))
|
||||
@@ -161,6 +175,10 @@ def truncate_assistant_text(
|
||||
text = "" if text is None else str(text)
|
||||
if len(text) <= head_chars + tail_chars:
|
||||
return text
|
||||
# 已截断过:中段恰好是本函数的省略标记 → 原样返回,保证幂等(否则会把
|
||||
# 省略计数改写成标记自身的长度)。
|
||||
if _ASSISTANT_OMIT_MARKER_RE.fullmatch(text[head_chars:len(text) - tail_chars]):
|
||||
return text
|
||||
omitted = len(text) - head_chars - tail_chars
|
||||
return (
|
||||
text[:head_chars]
|
||||
|
||||
@@ -260,6 +260,7 @@ class EmbedderConfig:
|
||||
max_retries: int = None # 最大重试次数
|
||||
retry_delay: float = None # 重试间隔(秒)
|
||||
timeout: int = None # 请求超时(秒)
|
||||
batch_size: int = None # 单次 HTTP 请求最多带多少条 input(embed_batch 按此切块)
|
||||
|
||||
# 内部平台扩展(extra_headers / extra_body 会透传给 OpenAI client)
|
||||
extra_headers: Optional[Dict[str, str]] = None
|
||||
@@ -294,6 +295,10 @@ class EmbedderConfig:
|
||||
# 请求切断并制造重试风暴。SDK 内层重试已关闭,15s 是每个显式 attempt
|
||||
# 的真实上限;部署可通过 MEMORY_EMBEDDER_TIMEOUT 覆盖。
|
||||
self.timeout = _get_env_int("MEMORY_EMBEDDER_TIMEOUT", 15)
|
||||
if self.batch_size is None:
|
||||
# 大批量(如重新 embed 整个库)不能一次 HTTP 发几千条:多数服务对
|
||||
# input 数组有上限,超了直接 400。按 batch_size 切块逐块发送。
|
||||
self.batch_size = _get_env_int("MEMORY_EMBEDDER_BATCH_SIZE", 64)
|
||||
if self.extra_headers is None:
|
||||
val = os.getenv("MEMORY_EMBEDDER_EXTRA_HEADERS")
|
||||
if val:
|
||||
|
||||
+105
-19
@@ -38,10 +38,12 @@ import importlib.util
|
||||
import json
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ..config import MemoryConfig
|
||||
from ..embedding import EmbeddingDimensionMismatchError
|
||||
from .workload import embedding_slot
|
||||
from ..utils.log_setup import get_request_id
|
||||
|
||||
@@ -187,6 +189,41 @@ def _redact_headers(headers: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any
|
||||
return out
|
||||
|
||||
|
||||
# Retry-After 的上限:服务端给出超长等待(或恶意值)时不让单次调用挂几十分钟。
|
||||
_RETRY_AFTER_MAX_S = 60.0
|
||||
|
||||
|
||||
def _retry_after_seconds(exc: Exception) -> Optional[float]:
|
||||
"""从 429 异常的响应头里取 Retry-After(秒数或 HTTP 日期),取不到返回 None。"""
|
||||
response = getattr(exc, "response", None)
|
||||
headers = getattr(response, "headers", None)
|
||||
if not headers:
|
||||
return None
|
||||
try:
|
||||
raw = headers.get("retry-after") or headers.get("Retry-After")
|
||||
except Exception:
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
raw = str(raw).strip()
|
||||
try:
|
||||
seconds = float(raw)
|
||||
except ValueError:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
when = parsedate_to_datetime(raw)
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=timezone.utc)
|
||||
seconds = (when - datetime.now(timezone.utc)).total_seconds()
|
||||
except Exception:
|
||||
return None
|
||||
if seconds != seconds: # NaN
|
||||
return None
|
||||
return min(max(0.0, seconds), _RETRY_AFTER_MAX_S)
|
||||
|
||||
|
||||
def _embeddings_api_url(base_url: str) -> str:
|
||||
"""OpenAI SDK posts to ``{base_url}/embeddings`` (base_url usually ends with /v1)."""
|
||||
base = (base_url or "").rstrip("/")
|
||||
@@ -205,7 +242,8 @@ class EmbedServiceConfig:
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
embedding_dims: int = 1536
|
||||
batch_size: int = 100
|
||||
# embed_batch 单次 HTTP 最多带多少条 input;超出按块顺序发送
|
||||
batch_size: int = 64
|
||||
enable_cache: bool = True
|
||||
cache_size: int = 10000
|
||||
max_retries: int = 5
|
||||
@@ -287,6 +325,7 @@ class EmbedService:
|
||||
max_retries=embedder.max_retries if embedder.max_retries is not None else 5,
|
||||
retry_delay=embedder.retry_delay if embedder.retry_delay is not None else 3.0,
|
||||
timeout=embedder.timeout if embedder.timeout is not None else 60,
|
||||
batch_size=max(1, int(getattr(embedder, "batch_size", None) or 64)),
|
||||
queue_batch_size=int(os.getenv("MEMORY_EMBED_BATCH_SIZE", "8")),
|
||||
queue_batch_window_ms=float(os.getenv("MEMORY_EMBED_BATCH_WINDOW_MS", "100")),
|
||||
extra_headers=embedder.extra_headers,
|
||||
@@ -640,6 +679,8 @@ class EmbedService:
|
||||
self,
|
||||
texts: List[str],
|
||||
use_cache: bool = True,
|
||||
*,
|
||||
max_retries: Optional[int] = None,
|
||||
) -> List[List[float]]:
|
||||
"""
|
||||
批量向量化
|
||||
@@ -647,17 +688,23 @@ class EmbedService:
|
||||
Args:
|
||||
texts: 文本列表
|
||||
use_cache: 是否使用缓存
|
||||
max_retries: 本次调用的重试次数;None 用配置值。长批量任务(重新
|
||||
embed 整个库)可以给更宽的重试预算,不影响在线路径。
|
||||
|
||||
Returns:
|
||||
向量列表
|
||||
"""
|
||||
vectors, _metas = await self.embed_batch_with_meta(texts, use_cache=use_cache)
|
||||
vectors, _metas = await self.embed_batch_with_meta(
|
||||
texts, use_cache=use_cache, max_retries=max_retries,
|
||||
)
|
||||
return vectors
|
||||
|
||||
async def embed_batch_with_meta(
|
||||
self,
|
||||
texts: List[str],
|
||||
use_cache: bool = True,
|
||||
*,
|
||||
max_retries: Optional[int] = None,
|
||||
) -> Tuple[List[List[float]], List[Dict[str, Any]]]:
|
||||
"""批量向量化,同时返回每条的 provider metadata(非 vector)。"""
|
||||
if not texts:
|
||||
@@ -682,7 +729,12 @@ class EmbedService:
|
||||
indices_to_embed.append(i)
|
||||
|
||||
if texts_to_embed:
|
||||
vectors, batch_metas = await self._embed_texts_with_meta(texts_to_embed)
|
||||
# max_retries 只在显式给出时透传:保持 `_embed_texts_with_meta(texts)`
|
||||
# 这个既有调用形状不变(测试与下游替身按它打桩)。
|
||||
retry_kwargs = {} if max_retries is None else {"max_retries": max_retries}
|
||||
vectors, batch_metas = await self._embed_texts_with_meta(
|
||||
texts_to_embed, **retry_kwargs,
|
||||
)
|
||||
|
||||
for j, vector in enumerate(vectors):
|
||||
idx = indices_to_embed[j]
|
||||
@@ -700,9 +752,9 @@ class EmbedService:
|
||||
return results, metas
|
||||
|
||||
async def _embed_texts_with_meta(
|
||||
self, texts: List[str]
|
||||
self, texts: List[str], *, max_retries: Optional[int] = None,
|
||||
) -> Tuple[List[List[float]], List[Dict[str, Any]]]:
|
||||
"""实际的向量化调用:**一次 HTTP 带走整批**;返回 (vectors, metas)。
|
||||
"""实际的向量化调用:**一次 HTTP 带走一块**;返回 (vectors, metas)。
|
||||
|
||||
早期实现是 `for text in texts: await _embed_openai([text])` 逐条串行,
|
||||
原意是"避免 batch size 限制",但代价极大:线上一批 8 条 × ~5.6s 串行
|
||||
@@ -719,21 +771,34 @@ class EmbedService:
|
||||
截断在这里做(发送前逐条过 `_truncate_for_embedding`):所有 embedding
|
||||
路径(embed / embed_batch / embed_queued / *_with_meta)最终都落到这个
|
||||
方法 —— 放在各入口会漏。理由与阈值见模块头部 `_EMBED_MAX_TOKENS`。
|
||||
|
||||
切块:一次带走整批只适用于小批。几千条的 input 数组会被多数服务直接
|
||||
400(input 上限),所以按 `batch_size`(默认 64)切块,块之间顺序发送。
|
||||
不超过一块时行为与切块前逐字相同(`batch_mode="single_request"`)。
|
||||
"""
|
||||
if not texts:
|
||||
return [], []
|
||||
|
||||
safe_texts = [_truncate_for_embedding(t) for t in texts]
|
||||
vectors, meta = await self._embed_openai(safe_texts)
|
||||
base_meta = dict(meta or {})
|
||||
base_meta["batch_mode"] = "single_request"
|
||||
base_meta["batch_input_count"] = len(safe_texts)
|
||||
|
||||
chunk = max(1, int(self._embed_config.batch_size or 64))
|
||||
vectors: List[List[float]] = []
|
||||
all_metas: List[Dict[str, Any]] = []
|
||||
for idx in range(len(safe_texts)):
|
||||
m = dict(base_meta)
|
||||
m["batch_index"] = idx
|
||||
all_metas.append(m)
|
||||
chunk_count = (len(safe_texts) + chunk - 1) // chunk
|
||||
for start in range(0, len(safe_texts), chunk):
|
||||
part = safe_texts[start:start + chunk]
|
||||
retry_kwargs = {} if max_retries is None else {"max_retries": max_retries}
|
||||
part_vectors, meta = await self._embed_openai(part, **retry_kwargs)
|
||||
base_meta = dict(meta or {})
|
||||
base_meta["batch_mode"] = "single_request" if chunk_count == 1 else "chunked"
|
||||
base_meta["batch_input_count"] = len(part)
|
||||
if chunk_count > 1:
|
||||
base_meta["batch_chunk_index"] = start // chunk
|
||||
base_meta["batch_chunk_count"] = chunk_count
|
||||
for idx in range(len(part)):
|
||||
m = dict(base_meta)
|
||||
m["batch_index"] = start + idx
|
||||
all_metas.append(m)
|
||||
vectors.extend(part_vectors)
|
||||
|
||||
self._last_embed_meta = all_metas[-1] if all_metas else {}
|
||||
return vectors, all_metas
|
||||
@@ -783,7 +848,7 @@ class EmbedService:
|
||||
)
|
||||
|
||||
async def _embed_openai(
|
||||
self, texts: List[str]
|
||||
self, texts: List[str], *, max_retries: Optional[int] = None,
|
||||
) -> Tuple[List[List[float]], Dict[str, Any]]:
|
||||
"""
|
||||
使用 OpenAI 兼容接口向量化
|
||||
@@ -873,7 +938,9 @@ class EmbedService:
|
||||
"endpoint_overridden": bool(_ov),
|
||||
}
|
||||
|
||||
_max_retries = max(0, int(self._embed_config.max_retries or 0))
|
||||
_max_retries = max(0, int(
|
||||
(self._embed_config.max_retries or 0) if max_retries is None else max_retries
|
||||
))
|
||||
_retry_delay = max(0.0, float(self._embed_config.retry_delay or 0.0))
|
||||
last_exc: Optional[Exception] = None
|
||||
|
||||
@@ -919,6 +986,17 @@ class EmbedService:
|
||||
f"got={len(vectors)}"
|
||||
)
|
||||
dims = [len(v) for v in vectors]
|
||||
expected_dims = self._embed_config.embedding_dims
|
||||
if expected_dims:
|
||||
bad = [d for d in dims if d != int(expected_dims)]
|
||||
if bad:
|
||||
# 宽度不对的向量写进集合会把集合的维度钉死在错误值上(Chroma
|
||||
# 首次 add 定维),或在已有集合上整批失败。确定性错误,不重试。
|
||||
raise EmbeddingDimensionMismatchError(
|
||||
"embedding dimension mismatch: "
|
||||
f"expected={int(expected_dims)} got={bad[0]} "
|
||||
f"model={self._embed_config.model}"
|
||||
)
|
||||
meta = {
|
||||
"http_status": raw.status_code,
|
||||
"response_headers": resp_headers,
|
||||
@@ -954,6 +1032,8 @@ class EmbedService:
|
||||
},
|
||||
)
|
||||
return vectors, meta
|
||||
except EmbeddingDimensionMismatchError:
|
||||
raise
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
_err_name = type(e).__name__
|
||||
@@ -987,12 +1067,18 @@ class EmbedService:
|
||||
if _retryable and attempt < _max_retries:
|
||||
# 逐出可能卡死的 client,下个 attempt 重建干净连接(恢复原防御语义)
|
||||
self._evict_client(_eff_base_url, _eff_api_key, _eff_timeout)
|
||||
_delay = _retry_delay
|
||||
_retry_after = _retry_after_seconds(e) if _status == 429 else None
|
||||
if _retry_after is not None:
|
||||
# 限流时服务端明确告诉了要等多久:按它等,再加一点抖动,
|
||||
# 避免多个 worker 在同一时刻一起醒来再次撞限流。
|
||||
_delay = max(_delay, _retry_after) + random.uniform(0, 0.25 * _retry_after)
|
||||
logger.warning(
|
||||
f"[embed] attempt {attempt + 1}/{_max_retries + 1} failed "
|
||||
f"({_err_name}): {_err_msg[:200]}; retry in {_retry_delay}s"
|
||||
f"({_err_name}): {_err_msg[:200]}; retry in {_delay:.2f}s"
|
||||
)
|
||||
if _retry_delay > 0:
|
||||
await asyncio.sleep(_retry_delay)
|
||||
if _delay > 0:
|
||||
await asyncio.sleep(_delay)
|
||||
continue
|
||||
self._log_embed_call(
|
||||
status="error",
|
||||
|
||||
@@ -22,6 +22,7 @@ import logging
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
|
||||
from ..models.memory import MemoryNode, MemoryLayer, MemoryStatus
|
||||
from ..config import MemoryConfig
|
||||
@@ -77,6 +78,21 @@ async def _run_in_kuzu_pool(func, *args, **kwargs):
|
||||
return await run_in_executor_guarded(_kuzu_executor, _measured)
|
||||
|
||||
|
||||
# 本进程里已打开、尚未 close 的 store。drop_graph 据此拒绝删掉一个正被
|
||||
# 活着的 client 使用的图库。WeakSet:store 被回收后自动消失。
|
||||
_LIVE_STORES: "weakref.WeakSet[KuzuGraphStore]" = weakref.WeakSet()
|
||||
|
||||
|
||||
def open_graph_paths() -> List[str]:
|
||||
"""Real paths of the graph databases live stores in this process hold open."""
|
||||
import os
|
||||
return [
|
||||
os.path.realpath(str(store._db_path))
|
||||
for store in list(_LIVE_STORES)
|
||||
if store._db is not None
|
||||
]
|
||||
|
||||
|
||||
def kuzu_executor_snapshot() -> Dict[str, Any]:
|
||||
with _kuzu_stats_lock:
|
||||
return {
|
||||
@@ -350,6 +366,7 @@ class KuzuGraphStore(GraphStoreBase):
|
||||
)
|
||||
self._db = db
|
||||
self._conn = conn
|
||||
_LIVE_STORES.add(self)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"GraphStore init timed out after 5s (Kuzu file lock contention). "
|
||||
@@ -2441,6 +2458,7 @@ class KuzuGraphStore(GraphStoreBase):
|
||||
async def close(self) -> None:
|
||||
"""关闭连接"""
|
||||
conn, db = self._conn, self._db
|
||||
_LIVE_STORES.discard(self)
|
||||
self._conn = None
|
||||
self._db = None
|
||||
self._available = False
|
||||
|
||||
@@ -89,6 +89,17 @@ def passes_created_after(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def physical_collection_name(base_name: Optional[str], dims: Optional[int]) -> str:
|
||||
"""The collection name a store opens for ``base_name`` at ``dims``.
|
||||
|
||||
The dims suffix keeps two embedding widths from sharing one collection.
|
||||
`hy_memory.reembed.vector_space_names` uses this too, so callers never
|
||||
re-derive the rule.
|
||||
"""
|
||||
base = base_name or "agent_memories"
|
||||
return f"{base}_{dims}" if dims else base
|
||||
|
||||
|
||||
class VectorStoreBase(ABC):
|
||||
"""
|
||||
向量存储抽象基类
|
||||
@@ -98,13 +109,10 @@ class VectorStoreBase(ABC):
|
||||
|
||||
def __init__(self, config: MemoryConfig):
|
||||
self.config = config
|
||||
base_name = config.vector_store.collection_name or "agent_memories"
|
||||
dims = config.vector_store.embedding_dims
|
||||
# 自动拼接维度后缀,避免不同 embedding 模型写入同一 collection 导致维度冲突
|
||||
if dims:
|
||||
self._collection_name = f"{base_name}_{dims}"
|
||||
else:
|
||||
self._collection_name = base_name
|
||||
self._collection_name = physical_collection_name(
|
||||
config.vector_store.collection_name, config.vector_store.embedding_dims,
|
||||
)
|
||||
|
||||
_supports_retrieval_clauses: bool = False
|
||||
_supports_narrow_episode_queries: bool = False
|
||||
|
||||
@@ -9,10 +9,12 @@ Agent Memory V2 - VectorStore (Chroma)
|
||||
- 同步 API → _run_in_vdb_pool() 包装为异步
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
import concurrent.futures
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import weakref
|
||||
|
||||
from ..models.memory import (
|
||||
MemoryNode, MemoryLayer, MemoryStatus,
|
||||
@@ -33,6 +35,30 @@ _vdb_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
)
|
||||
|
||||
|
||||
# 本进程里已 initialize、尚未 close 的 store。drop_vector_space 据此拒绝删掉
|
||||
# 一个正被活着的 client 使用的集合。WeakSet:store 被回收后自动消失。
|
||||
_LIVE_STORES: "weakref.WeakSet[ChromaVectorStore]" = weakref.WeakSet()
|
||||
|
||||
|
||||
def _location_key(vs_config) -> Tuple[str, str]:
|
||||
if getattr(vs_config, "host", None):
|
||||
return ("http", f"{vs_config.host}:{vs_config.port or 8000}")
|
||||
return ("local", os.path.realpath(str(vs_config.persist_directory or "")))
|
||||
|
||||
|
||||
def open_collection_names(location: Tuple[str, str]) -> Set[str]:
|
||||
"""Collections that live stores in this process hold open at ``location``."""
|
||||
names: Set[str] = set()
|
||||
for store in list(_LIVE_STORES):
|
||||
if store._client is None or _location_key(store.config.vector_store) != location:
|
||||
continue
|
||||
names.add(store._collection_name)
|
||||
if store._entity_collection is not None:
|
||||
from ..pipelines._retrieval.config import entity_collection_name
|
||||
names.add(entity_collection_name(store._collection_name))
|
||||
return names
|
||||
|
||||
|
||||
async def _run_in_vdb_pool(func, *args, **kwargs):
|
||||
"""Run a synchronous Chroma call in the dedicated VDB executor."""
|
||||
call = functools.partial(func, *args, **kwargs) if args or kwargs else func
|
||||
@@ -88,6 +114,7 @@ class ChromaVectorStore(VectorStoreBase):
|
||||
return client, collection
|
||||
|
||||
self._client, self._collection = await _run_in_vdb_pool(_init)
|
||||
_LIVE_STORES.add(self)
|
||||
logger.debug(f"VectorStore initialized (Chroma), collection={self._collection_name}")
|
||||
|
||||
# ================================================================
|
||||
@@ -561,6 +588,7 @@ class ChromaVectorStore(VectorStoreBase):
|
||||
async def close(self) -> None:
|
||||
"""关闭客户端"""
|
||||
# Chroma PersistentClient 无需显式关闭
|
||||
_LIVE_STORES.discard(self)
|
||||
self._client = None
|
||||
self._collection = None
|
||||
self._entity_collection = None
|
||||
|
||||
@@ -280,6 +280,22 @@ class MemoryWriter(WritePipeline):
|
||||
custom["source_raw_memory_ids"] = source_ids
|
||||
return custom
|
||||
|
||||
def _flat_trimmed_raw(self, request: WriteRequest) -> str:
|
||||
"""把 request.messages 按 raw 统一规则裁剪后拼成 L1_RAW 文本。
|
||||
|
||||
只裁落库副本;request.messages 不动(本次 extractor 输入仍用完整 messages)。
|
||||
"""
|
||||
import copy as _copy
|
||||
from ..utils.raw_trim import trim_raw_messages
|
||||
|
||||
_coding_cfg = getattr(getattr(self, "config", None), "coding", None)
|
||||
_trimmed_req = _copy.copy(request)
|
||||
_trimmed_req.messages = trim_raw_messages(
|
||||
request.messages,
|
||||
tool_max_bytes=getattr(_coding_cfg, "tool_result_max_bytes", None),
|
||||
)
|
||||
return _trimmed_req.get_flat_content()
|
||||
|
||||
@staticmethod
|
||||
def _attach_client_meta(custom: Dict[str, Any], request: Optional[WriteRequest]) -> None:
|
||||
"""给新建节点挂上调用方 meta(单份 list)。
|
||||
@@ -2554,9 +2570,14 @@ class MemoryWriter(WritePipeline):
|
||||
try:
|
||||
# 参数校验
|
||||
content = request.content
|
||||
# raw 在写入时就按统一规则裁好(见 utils/raw_trim.py):工具输出、长
|
||||
# assistant 回复不再原样长期存储;历史拼接直接用存下来的文本,不再二次裁剪。
|
||||
# request.messages 保持原样——本次 extractor 输入仍是完整 messages。
|
||||
_raw_trimmed = False
|
||||
if not content and request.has_messages():
|
||||
content = request.get_flat_content()
|
||||
content = self._flat_trimmed_raw(request)
|
||||
request.content = content
|
||||
_raw_trimmed = True
|
||||
|
||||
if not content:
|
||||
response.error_code = 400
|
||||
@@ -2627,6 +2648,9 @@ class MemoryWriter(WritePipeline):
|
||||
vector_store = await self._get_vector_store()
|
||||
layer_enum = MemoryLayer.from_string(suggested_layer)
|
||||
_raw_custom: Dict[str, Any] = {}
|
||||
if _raw_trimmed:
|
||||
from ..utils.raw_trim import RAW_TRIMMED_KEY
|
||||
_raw_custom[RAW_TRIMMED_KEY] = True
|
||||
self._attach_client_meta(_raw_custom, request)
|
||||
mem_node = MemoryNode(
|
||||
user_id=request.user_id,
|
||||
@@ -2929,13 +2953,16 @@ class MemoryWriter(WritePipeline):
|
||||
agent_id: Optional[str] = None,
|
||||
exclude_memory_id: str = "",
|
||||
max_turns: int = 20,
|
||||
max_chars_assistant: int = 500,
|
||||
max_chars_assistant: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
获取该用户最近的 L1_RAW 对话记录,按轮次拆分后拼成 history context。
|
||||
|
||||
每条 L1_RAW 的 content 是多轮对话([user]: ...\n[assistant]: ...)。
|
||||
拆成单条 message 后,user 消息完整保留,assistant 消息截取前 max_chars_assistant 字符。
|
||||
raw 在写入时已按 utils/raw_trim.py 的规则裁好(custom[RAW_TRIMMED_KEY]),
|
||||
这里拆成单条 message 后直接拼接,不再二次裁剪。该规则之前写入的旧 raw
|
||||
没有这个标记,仍按同一(幂等)规则裁剪后再拼,避免旧的长回复 / 工具输出
|
||||
撑大 prompt。max_chars_assistant 只作用于这些旧 raw,默认同写入规则。
|
||||
最终取最近 max_turns 条 message(注意单位是 message 条数,非对话轮数;
|
||||
调用方按 k 轮 × 2 传入)。
|
||||
|
||||
@@ -2977,23 +3004,33 @@ class MemoryWriter(WritePipeline):
|
||||
# 避免把正文里形如 "[注]: xxx" 的普通文本误判成消息边界。
|
||||
import re as _re
|
||||
from .base import parse_user_name_line
|
||||
_KNOWN_ROLES = {"user", "assistant", "system", "tool"}
|
||||
# 带方括号的任意 label(含对方真人名字)
|
||||
_labeled_prefix = _re.compile(r"^\[([^\]\n]{1,32})\]:\s?")
|
||||
# 无方括号的已知 role(历史数据容错,沿用旧正则的 `\[?...\]?` 宽容度)
|
||||
_bare_role_prefix = _re.compile(r"^(user|assistant|system|tool):\s?")
|
||||
|
||||
# (label, content) 元组列表;label 为 role 或对方真人名字
|
||||
parsed: List[Tuple[str, str]] = []
|
||||
from ..utils.raw_trim import (
|
||||
DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
RAW_TRIMMED_KEY,
|
||||
trim_raw_body,
|
||||
)
|
||||
if max_chars_assistant is None:
|
||||
max_chars_assistant = DEFAULT_RAW_ASSISTANT_MAX_CHARS
|
||||
_coding_cfg = getattr(getattr(self, "config", None), "coding", None)
|
||||
_tool_max_bytes = getattr(_coding_cfg, "tool_result_max_bytes", None)
|
||||
|
||||
# (label, content, 是否旧 raw 需兼容裁剪) 元组列表;label 为 role 或对方真人名字
|
||||
parsed: List[Tuple[str, str, bool]] = []
|
||||
user_name: str = ""
|
||||
for node in nodes:
|
||||
content = node.content or ""
|
||||
_legacy = not bool((node.custom or {}).get(RAW_TRIMMED_KEY))
|
||||
for _line_idx, line in enumerate(content.split("\n")):
|
||||
m = _labeled_prefix.match(line) or _bare_role_prefix.match(line)
|
||||
if m:
|
||||
label = m.group(1)
|
||||
body = line[m.end():]
|
||||
parsed.append((label, body))
|
||||
parsed.append((label, body, _legacy))
|
||||
continue
|
||||
# 名字行只可能是每个 node content 的第一行(get_flat_content 插在文首)。
|
||||
# 逐 node 判断而非用 `not parsed`:parsed 跨 node 累积,第二个 node
|
||||
@@ -3006,18 +3043,18 @@ class MemoryWriter(WritePipeline):
|
||||
continue
|
||||
if parsed:
|
||||
# 续行:拼回上一条 message(保留换行)
|
||||
prev_label, prev_body = parsed[-1]
|
||||
parsed[-1] = (prev_label, prev_body + "\n" + line)
|
||||
prev_label, prev_body, prev_legacy = parsed[-1]
|
||||
parsed[-1] = (prev_label, prev_body + "\n" + line, prev_legacy)
|
||||
else:
|
||||
# 开头就是无前缀行(异常/老数据):当成 user 消息兜底
|
||||
parsed.append(("user", line))
|
||||
parsed.append(("user", line, _legacy))
|
||||
|
||||
# 取最近 max_turns 条 message(按 message 截断,绝不从消息中间切开)
|
||||
recent = parsed[-max_turns:] if len(parsed) > max_turns else parsed
|
||||
if not recent:
|
||||
return ""
|
||||
|
||||
# 格式化:非 user 侧整条截断(含对方真人),user 完整保留
|
||||
# 格式化:新 raw 直接拼接;旧 raw 按写入同一规则兼容裁剪
|
||||
all_messages: List[str] = []
|
||||
if user_name:
|
||||
from .base import render_user_name_line
|
||||
@@ -3025,17 +3062,18 @@ class MemoryWriter(WritePipeline):
|
||||
|
||||
_name_line = render_user_name_line(
|
||||
user_name,
|
||||
is_chinese("\n".join(b for _, b in recent)),
|
||||
is_chinese("\n".join(b for _, b, _ in recent)),
|
||||
)
|
||||
if _name_line:
|
||||
all_messages.append(_name_line)
|
||||
for label, body in recent:
|
||||
for label, body, legacy in recent:
|
||||
if legacy:
|
||||
body = trim_raw_body(
|
||||
label, body,
|
||||
assistant_max_chars=max_chars_assistant,
|
||||
tool_max_bytes=_tool_max_bytes,
|
||||
)
|
||||
body = body.strip()
|
||||
# 截断口径:assistant + 对方真人(非已知 role 的 label)。
|
||||
# user 完整保留;system / tool 沿用历史行为(不截断)。
|
||||
_truncate = label == "assistant" or label not in _KNOWN_ROLES
|
||||
if _truncate and len(body) > max_chars_assistant:
|
||||
body = body[:max_chars_assistant] + "..."
|
||||
all_messages.append(f"[{label}]: {body}")
|
||||
|
||||
return "\n".join(all_messages)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
原始对话(raw)落库前的统一裁剪规则(规则,零 LLM)。
|
||||
|
||||
raw 会被长期保存(MemHub 已取消 raw 的保留期限;lite 模式下 raw 是唯一存储),
|
||||
不裁剪会把工具输出、大段粘贴日志原样存下来。因此 raw 在**写入时**就裁好,
|
||||
读取方(extractor 的「最近 k 条」历史注入)直接拼接,不再二次裁剪。
|
||||
|
||||
规则按说话人标签(ChatMessage.speaker_label() / L1_RAW 里的 ``[label]``):
|
||||
|
||||
- ``user``:完整保留;
|
||||
- ``system``:完整保留(与历史注入原口径一致);
|
||||
- ``tool``:头尾保留、中段替换为 ``[...N bytes omitted by truncate...]``,
|
||||
上限为 ``coding.tool_result_max_bytes``(env ``MEMORY_CODING_TOOL_RESULT_MAX_BYTES``,
|
||||
默认 2048),与 coding 链路同一个函数;
|
||||
- 其余(``assistant``、对方真人名字等非已知 role 的 label):strip 后超过
|
||||
``assistant_max_chars``(默认 500,即历史注入原来的 ``max_chars_assistant``)
|
||||
时截成前 N 个字符 + ``...``。
|
||||
|
||||
所有规则确定且幂等:对已裁剪的文本再裁一次结果不变。
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from ..pipelines.base import ChatMessage
|
||||
|
||||
# 历史注入原 `_get_recent_history(max_chars_assistant=500)` 的默认值,现为唯一来源。
|
||||
DEFAULT_RAW_ASSISTANT_MAX_CHARS = 500
|
||||
|
||||
# L1_RAW 节点 custom 上的标记:内容已按本模块规则在写入时裁剪。
|
||||
# 历史拼接据此区分「新 raw 直接拼接」与「本规则之前写入的旧 raw 需兼容裁剪」。
|
||||
RAW_TRIMMED_KEY = "raw_trimmed"
|
||||
|
||||
# 不裁剪的标签(tool 单独处理)
|
||||
_KEEP_LABELS = frozenset({"user", "system"})
|
||||
|
||||
|
||||
def trim_raw_body(
|
||||
label: str,
|
||||
body: str,
|
||||
*,
|
||||
assistant_max_chars: int = DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
tool_max_bytes: Optional[int] = None,
|
||||
) -> str:
|
||||
"""按说话人标签裁剪单条消息正文。幂等。"""
|
||||
if not isinstance(body, str):
|
||||
body = "" if body is None else str(body)
|
||||
if label in _KEEP_LABELS:
|
||||
return body
|
||||
if label == "tool":
|
||||
from ..coding.preproc import (
|
||||
DEFAULT_MAX_TOOL_RESULT_BYTES,
|
||||
truncate_tool_result_text,
|
||||
)
|
||||
|
||||
return truncate_tool_result_text(
|
||||
body, max_bytes=int(tool_max_bytes or DEFAULT_MAX_TOOL_RESULT_BYTES),
|
||||
)
|
||||
stripped = body.strip()
|
||||
if len(stripped) > assistant_max_chars:
|
||||
return stripped[:assistant_max_chars] + "..."
|
||||
return body
|
||||
|
||||
|
||||
def trim_raw_messages(
|
||||
messages: List[ChatMessage],
|
||||
*,
|
||||
assistant_max_chars: int = DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
tool_max_bytes: Optional[int] = None,
|
||||
) -> List[ChatMessage]:
|
||||
"""返回裁剪后的新列表;不修改入参(messages 仍要原样给 extractor)。"""
|
||||
out: List[ChatMessage] = []
|
||||
for m in messages:
|
||||
new_content = trim_raw_body(
|
||||
m.speaker_label(),
|
||||
m.content,
|
||||
assistant_max_chars=assistant_max_chars,
|
||||
tool_max_bytes=tool_max_bytes,
|
||||
)
|
||||
if new_content == m.content:
|
||||
out.append(m)
|
||||
continue
|
||||
out.append(ChatMessage(
|
||||
role=m.role,
|
||||
content=new_content,
|
||||
name=m.name,
|
||||
tool_calls=list(m.tool_calls),
|
||||
tool_call_id=m.tool_call_id,
|
||||
tool_name=m.tool_name,
|
||||
))
|
||||
return out
|
||||
+2
-2
@@ -18,7 +18,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.9`。MemHub 源码版本为 `1.0.5`
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.10`。MemHub 源码版本为 `1.0.5`
|
||||
(build 36),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.9`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.10`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
@@ -2352,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.9",
|
||||
"versionInfo": "2.0.10",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
|
||||
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
|
||||
column is filled in as part of release review, and the SBOM records
|
||||
`NOASSERTION` until then rather than guessing.
|
||||
|
||||
## hy-memory 2.0.9
|
||||
## hy-memory 2.0.10
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
+243
-25
@@ -122,6 +122,14 @@ import {
|
||||
doctorExitCode,
|
||||
renderDoctorText,
|
||||
} from "./doctor-report.js";
|
||||
import {
|
||||
activateWithEmbeddingSwitch,
|
||||
cleanupSpace,
|
||||
listSpaces,
|
||||
type SwitchIo,
|
||||
switchCancel,
|
||||
switchStatus,
|
||||
} from "./embedding-switch-flow.js";
|
||||
import {
|
||||
EXIT_FAILED,
|
||||
EXIT_OK,
|
||||
@@ -188,6 +196,13 @@ import {
|
||||
removeManagedCliLink,
|
||||
rollbackManagedCliLink,
|
||||
} from "./managed-cli-link.js";
|
||||
import {
|
||||
type FeaturesDocument,
|
||||
featuresExitCode,
|
||||
parseFeaturesCommand,
|
||||
renderFeaturesText,
|
||||
runFeaturesCommand,
|
||||
} from "./memory-features-flow.js";
|
||||
import {
|
||||
type EvolveDocument,
|
||||
evolveExitCode,
|
||||
@@ -241,7 +256,6 @@ import {
|
||||
renderModelReportText,
|
||||
} from "./model-report.js";
|
||||
import {
|
||||
activateModelProfile,
|
||||
editModelProfile,
|
||||
type ModelWriteDocument,
|
||||
modelWriteExitCode,
|
||||
@@ -1696,6 +1710,18 @@ async function runCommand(
|
||||
);
|
||||
if (apiKey === undefined) return usage();
|
||||
}
|
||||
if (
|
||||
parsed.kind === "spaces" &&
|
||||
parsed.cleanup !== undefined &&
|
||||
!parsed.confirmed
|
||||
) {
|
||||
return emitConfirmPreview(asJson, {
|
||||
command: "models spaces",
|
||||
target: parsed.cleanup,
|
||||
effect: `删除已停用的向量空间 ${parsed.cleanup}(不可恢复;正在使用的空间不能删除)`,
|
||||
retry: `${PRODUCT} models spaces --cleanup ${parsed.cleanup} --yes`,
|
||||
});
|
||||
}
|
||||
if (parsed.kind === "remove" && !parsed.confirmed) {
|
||||
return emitConfirmPreview(asJson, {
|
||||
command: "models remove",
|
||||
@@ -1718,6 +1744,50 @@ async function runCommand(
|
||||
return connected.exitCode;
|
||||
}
|
||||
const { reader, operations } = connected;
|
||||
// Ctrl-C while following a switch stops watching, never the switch.
|
||||
const interrupt = new AbortController();
|
||||
const switchIo: SwitchIo = {
|
||||
reader,
|
||||
operations,
|
||||
emit: (text) => {
|
||||
emit(text);
|
||||
},
|
||||
asJson,
|
||||
interactive:
|
||||
!asJson && (controls.stdinIsTTY ?? process.stdin.isTTY) === true,
|
||||
confirm: async (question) => {
|
||||
const prompter =
|
||||
controls.wizardPrompter ?? (await createTerminalPrompter());
|
||||
try {
|
||||
return /^(y|yes)$/i.test((await prompter.ask(question)).trim());
|
||||
} finally {
|
||||
if ("close" in prompter && typeof prompter.close === "function") {
|
||||
prompter.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
...(controls.watchWait ? { wait: controls.watchWait } : {}),
|
||||
signal: controls.watchSignal ?? interrupt.signal,
|
||||
};
|
||||
const withInterrupt = async (run: () => Promise<number>) => {
|
||||
const onInterrupt = () => interrupt.abort();
|
||||
process.once("SIGINT", onInterrupt);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
process.removeListener("SIGINT", onInterrupt);
|
||||
}
|
||||
};
|
||||
if (parsed.kind === "switch") {
|
||||
return parsed.action === "status"
|
||||
? await switchStatus(switchIo)
|
||||
: await switchCancel(switchIo);
|
||||
}
|
||||
if (parsed.kind === "spaces") {
|
||||
return parsed.cleanup === undefined
|
||||
? await listSpaces(switchIo)
|
||||
: await cleanupSpace(parsed.cleanup, switchIo);
|
||||
}
|
||||
if (parsed.kind === "add") {
|
||||
const created = await createModelProfile(
|
||||
config,
|
||||
@@ -1732,33 +1802,66 @@ async function runCommand(
|
||||
emitFlowDocument(created, asJson);
|
||||
return flowExitCode(created);
|
||||
}
|
||||
const activated = await activateModelProfile(
|
||||
String(created.receipt.profile_id),
|
||||
// Core activates only a probed profile: probe first, then the same
|
||||
// path as `models activate`, so an embedding change shows its plan.
|
||||
const profileId = String(created.receipt.profile_id);
|
||||
const probe = await probeUntilSettled(
|
||||
reader,
|
||||
operations,
|
||||
profileId,
|
||||
controls.watchWait,
|
||||
);
|
||||
const document = {
|
||||
...created,
|
||||
activation:
|
||||
activated.state === "committed"
|
||||
? { state: "committed" }
|
||||
: { state: activated.state, code: activated.code },
|
||||
};
|
||||
const probed =
|
||||
probe.state === "completed" &&
|
||||
Boolean(
|
||||
(
|
||||
(probe.receipt.result as Record<string, unknown> | undefined)
|
||||
?.capabilities as Record<string, unknown> | undefined
|
||||
)?.embedding_available,
|
||||
);
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
? `${JSON.stringify({
|
||||
...created,
|
||||
probe: {
|
||||
state: probe.state,
|
||||
...("operation_id" in probe && probe.operation_id
|
||||
? { operation_id: probe.operation_id }
|
||||
: {}),
|
||||
...("code" in probe ? { code: probe.code } : {}),
|
||||
},
|
||||
})}\n`
|
||||
: `${renderFlowText(created)}${
|
||||
activated.state === "committed"
|
||||
? "已设为当前使用的模型配置。\n"
|
||||
: `设为当前使用未完成:${activated.state}(${activated.code})\n`
|
||||
probed
|
||||
? "探测通过。\n"
|
||||
: `探测未通过(${"code" in probe ? probe.code : probe.state}),没有设为当前使用;用 ${PRODUCT} models test ${profileId} 查看原因。\n`
|
||||
}`,
|
||||
);
|
||||
return activated.state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||
if (!probed) return EXIT_FAILED;
|
||||
return await withInterrupt(() =>
|
||||
activateWithEmbeddingSwitch(
|
||||
profileId,
|
||||
{ confirmed: parsed.confirmed, follow: parsed.follow },
|
||||
switchIo,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (parsed.kind === "edit" || parsed.kind === "activate") {
|
||||
const document =
|
||||
parsed.kind === "edit"
|
||||
? await editModelProfile(parsed.profileId, config, apiKey, operations)
|
||||
: await activateModelProfile(parsed.profileId, operations);
|
||||
if (parsed.kind === "activate") {
|
||||
return await withInterrupt(() =>
|
||||
activateWithEmbeddingSwitch(
|
||||
parsed.profileId,
|
||||
{ confirmed: parsed.confirmed, follow: parsed.follow },
|
||||
switchIo,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (parsed.kind === "edit") {
|
||||
const document = await editModelProfile(
|
||||
parsed.profileId,
|
||||
config,
|
||||
apiKey,
|
||||
operations,
|
||||
);
|
||||
emitModelWriteDocument(document, asJson);
|
||||
return modelWriteExitCode(document);
|
||||
}
|
||||
@@ -2242,6 +2345,40 @@ async function runCommand(
|
||||
);
|
||||
return outcome.state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
if (
|
||||
named === "settings" &&
|
||||
arguments_.slice(1).find((value) => value !== "--json") === "features"
|
||||
) {
|
||||
const parsed = parseFeaturesCommand(
|
||||
arguments_
|
||||
.slice(1)
|
||||
.filter((value, index, all) => index !== all.indexOf("features")),
|
||||
);
|
||||
if (!parsed) return usage();
|
||||
const connected = await connectRuntime(environment);
|
||||
if (!connected.ok) {
|
||||
const document: FeaturesDocument = {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "settings features",
|
||||
state: "unavailable",
|
||||
code: connected.code,
|
||||
};
|
||||
emit(
|
||||
asJson ? `${JSON.stringify(document)}\n` : renderFeaturesText(document),
|
||||
);
|
||||
return connected.exitCode;
|
||||
}
|
||||
const document = await runFeaturesCommand(
|
||||
parsed,
|
||||
connected.reader,
|
||||
connected.operations,
|
||||
);
|
||||
emit(
|
||||
asJson ? `${JSON.stringify(document)}\n` : renderFeaturesText(document),
|
||||
);
|
||||
return featuresExitCode(document);
|
||||
}
|
||||
if (named === "settings") {
|
||||
const parsed = parseSettingsCommand(arguments_.slice(1));
|
||||
if (!parsed) return usage();
|
||||
@@ -5248,6 +5385,8 @@ type ModelsCommand =
|
||||
configFile: string;
|
||||
apiKeyFile?: string;
|
||||
activate: boolean;
|
||||
confirmed: boolean;
|
||||
follow?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "edit";
|
||||
@@ -5255,7 +5394,14 @@ type ModelsCommand =
|
||||
configFile: string;
|
||||
apiKeyFile?: string;
|
||||
}
|
||||
| { kind: "activate"; profileId: string }
|
||||
| {
|
||||
kind: "activate";
|
||||
profileId: string;
|
||||
confirmed: boolean;
|
||||
follow?: boolean;
|
||||
}
|
||||
| { kind: "switch"; action: "status" | "cancel" }
|
||||
| { kind: "spaces"; cleanup?: string; confirmed: boolean }
|
||||
| { kind: "test"; profileId: string }
|
||||
| { kind: "receipt"; operationId: string }
|
||||
| { kind: "remove"; profileId: string; confirmed: boolean };
|
||||
@@ -5296,16 +5442,46 @@ function parseModelsCommand(rest: readonly string[]): ModelsCommand | null {
|
||||
const flags = readFlags(
|
||||
tokens.slice(1),
|
||||
["--config-file", "--api-key-file"],
|
||||
["--activate"],
|
||||
["--activate", "--yes", "--wait", "--no-wait"],
|
||||
);
|
||||
const configFile = flags?.values.get("--config-file");
|
||||
if (!flags || configFile === undefined) return null;
|
||||
const follow = followFlag(flags.switches);
|
||||
if (follow === null) return null;
|
||||
const activate = flags.switches.has("--activate");
|
||||
// The switch flags only mean something when the add also activates.
|
||||
if (
|
||||
!activate &&
|
||||
["--yes", "--wait", "--no-wait"].some((flag) => flags.switches.has(flag))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const apiKeyFile = flags.values.get("--api-key-file");
|
||||
return {
|
||||
kind: "add",
|
||||
configFile,
|
||||
...(apiKeyFile === undefined ? {} : { apiKeyFile }),
|
||||
activate: flags.switches.has("--activate"),
|
||||
activate,
|
||||
confirmed: flags.switches.has("--yes"),
|
||||
...(follow === undefined ? {} : { follow }),
|
||||
};
|
||||
}
|
||||
if (action === "switch") {
|
||||
if (tokens.length !== 2 || (target !== "status" && target !== "cancel")) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "switch", action: target };
|
||||
}
|
||||
if (action === "spaces") {
|
||||
const flags = readFlags(tokens.slice(1), ["--cleanup"], ["--yes"]);
|
||||
if (!flags) return null;
|
||||
const cleanup = flags.values.get("--cleanup");
|
||||
if (cleanup === undefined && flags.switches.has("--yes")) return null;
|
||||
if (cleanup !== undefined && !OPERATION_ID.test(cleanup)) return null;
|
||||
return {
|
||||
kind: "spaces",
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
confirmed: flags.switches.has("--yes"),
|
||||
};
|
||||
}
|
||||
if (target === undefined || !OPERATION_ID.test(target)) return null;
|
||||
@@ -5326,8 +5502,21 @@ function parseModelsCommand(rest: readonly string[]): ModelsCommand | null {
|
||||
};
|
||||
}
|
||||
if (action === "activate") {
|
||||
const flags = readFlags(tokens.slice(2), [], ["--confirm", "--yes"]);
|
||||
return flags ? { kind: "activate", profileId: target } : null;
|
||||
const flags = readFlags(
|
||||
tokens.slice(2),
|
||||
[],
|
||||
["--confirm", "--yes", "--wait", "--no-wait"],
|
||||
);
|
||||
if (!flags) return null;
|
||||
const follow = followFlag(flags.switches);
|
||||
if (follow === null) return null;
|
||||
return {
|
||||
kind: "activate",
|
||||
profileId: target,
|
||||
// `--confirm` stays the hidden alias of `--yes`.
|
||||
confirmed: flags.switches.has("--yes") || flags.switches.has("--confirm"),
|
||||
...(follow === undefined ? {} : { follow }),
|
||||
};
|
||||
}
|
||||
if (action === "test") {
|
||||
return tokens.length === 2 ? { kind: "test", profileId: target } : null;
|
||||
@@ -5350,6 +5539,35 @@ function parseModelsCommand(rest: readonly string[]): ModelsCommand | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `--wait` / `--no-wait`: undefined when neither, null when both. */
|
||||
function followFlag(switches: ReadonlySet<string>): boolean | undefined | null {
|
||||
const wait = switches.has("--wait");
|
||||
const noWait = switches.has("--no-wait");
|
||||
if (wait && noWait) return null;
|
||||
return wait ? true : noWait ? false : undefined;
|
||||
}
|
||||
|
||||
/** Start a probe and read its receipt until it settles (about two minutes). */
|
||||
async function probeUntilSettled(
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
profileId: string,
|
||||
wait?: (milliseconds: number, signal: AbortSignal) => Promise<void>,
|
||||
): Promise<ModelProbeFlowDocument> {
|
||||
let document = await startModelProbe(reader, operations, profileId);
|
||||
const pause =
|
||||
wait ??
|
||||
((milliseconds: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
const signal = new AbortController().signal;
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
if (document.state !== "pending" || !document.operation_id) break;
|
||||
await pause(1_000, signal);
|
||||
document = await readModelProbeReceipt(reader, document.operation_id);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
type SourcesCommand =
|
||||
| {
|
||||
kind: "ids";
|
||||
|
||||
@@ -361,9 +361,9 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "models",
|
||||
summary:
|
||||
"管理生成模型配置。add/test 直接提交并返回操作 ID(异步,用 receipt 查结果);edit/activate 直接执行;remove 不可逆,需 --yes。<profile-id>/<operation-id> 都是 UUID;脚本模式的凭据只从受控文件读取。",
|
||||
"管理生成模型配置。add/test 直接提交并返回操作 ID(异步,用 receipt 查结果);edit 直接执行;activate 换了向量模型(模型或维度)时先显示切换计划,需 --yes 确认后重新生成全部向量,switch 查看或取消、spaces 查看或清理旧向量数据;remove 不可逆,需 --yes。<profile-id>/<operation-id> 都是 UUID;脚本模式的凭据只从受控文件读取。",
|
||||
usage:
|
||||
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models add --config-file <path> [--api-key-file <path>] [--activate] [--json]\n memhub models test <profile-id> [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--json]\n memhub models remove <profile-id> [--yes] [--json]",
|
||||
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models add --config-file <path> [--api-key-file <path>] [--activate [--yes] [--wait|--no-wait]] [--json]\n memhub models test <profile-id> [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--yes] [--wait|--no-wait] [--json]\n memhub models switch <status|cancel> [--json]\n memhub models spaces [--cleanup <space-id> --yes] [--json]\n memhub models remove <profile-id> [--yes] [--json]",
|
||||
alternative_usage: "memhub models setup",
|
||||
options: [
|
||||
{
|
||||
@@ -377,7 +377,17 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
{
|
||||
flag: "--activate",
|
||||
summary: "add 成功后把新配置设为当前使用。",
|
||||
summary:
|
||||
"add 成功后先探测,再按 activate 的规则设为当前使用(向量模型变化时同样要确认切换)。",
|
||||
},
|
||||
{
|
||||
flag: "--wait | --no-wait",
|
||||
summary:
|
||||
"activate 开始向量模型切换后是否跟随进度;终端里默认跟随,Ctrl-C 只停止显示。",
|
||||
},
|
||||
{
|
||||
flag: "--cleanup <space-id>",
|
||||
summary: "spaces:立即删除一个已停用的向量空间(不可恢复,需 --yes)。",
|
||||
},
|
||||
DESTRUCTIVE_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
@@ -451,13 +461,26 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "settings",
|
||||
summary:
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。",
|
||||
usage: "memhub settings mode [lite|pro|ultra] [--yes] [--json]",
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。",
|
||||
usage:
|
||||
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
|
||||
" memhub settings features [--json]\n" +
|
||||
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
|
||||
" memhub settings features reset [<name> ...] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "mode [lite|pro|ultra]",
|
||||
summary: "不带模式只显示当前模式;带模式时先显示切换计划。",
|
||||
},
|
||||
{
|
||||
flag: "features set <name>=<true|false>",
|
||||
summary:
|
||||
"开关一个或多个 SDK 功能;功能在当前模式下不生效时会注明原因(例如 graph_rerank 需要 ultra)。",
|
||||
},
|
||||
{
|
||||
flag: "features reset [<name> ...]",
|
||||
summary: "把指定功能(不写则全部)恢复为 SDK 默认值。",
|
||||
},
|
||||
CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
|
||||
@@ -635,6 +635,8 @@ describe("documented commands", () => {
|
||||
"receipt",
|
||||
"edit",
|
||||
"activate",
|
||||
"switch",
|
||||
"spaces",
|
||||
"remove",
|
||||
],
|
||||
// Source processing is one Freeze/Thaw switch under `sync`; the obsolete
|
||||
|
||||
@@ -97,7 +97,12 @@ export function contextExitCode(document: ContextSearchDocument): number {
|
||||
}
|
||||
|
||||
export function renderContextText(document: ContextSearchDocument): string {
|
||||
if (document.state === "unavailable") return "暂时无法查记忆,请稍后重试。\n";
|
||||
if (document.state === "unavailable") {
|
||||
// No lexical fallback: without the embedding service nothing is searched.
|
||||
return document.code === "embedding_unavailable"
|
||||
? "向量服务不可用,无法检索;请检查向量模型配置。\n"
|
||||
: "暂时无法查记忆,请稍后重试。\n";
|
||||
}
|
||||
if (document.state === "denied") return "当前应用没有查记忆权限。\n";
|
||||
if (document.state === "invalid") return "查记忆返回了无法识别的结果。\n";
|
||||
if (document.state === "rejected") {
|
||||
|
||||
@@ -8,6 +8,9 @@ import axios from "axios";
|
||||
*/
|
||||
const READ_ONLY_PATHS = [
|
||||
"/api/v1/call-events",
|
||||
// Read-only: the registry plus a scan of the vector store's collection names.
|
||||
"/api/v1/embedding-spaces",
|
||||
"/api/v1/embedding-switches/current",
|
||||
"/api/v1/import-jobs",
|
||||
"/api/v1/live-sync",
|
||||
"/api/v1/memory-processing",
|
||||
@@ -19,6 +22,7 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v2/user-profile",
|
||||
"/api/v2/user-profile/versions",
|
||||
"/api/v1/settings/memory-mode",
|
||||
"/api/v1/settings/memory-features",
|
||||
"/api/v2/evolve",
|
||||
] as const;
|
||||
/** One evolve run's receipt (`memhub evolve --wait`). */
|
||||
@@ -162,10 +166,23 @@ export function createCoreReader(attachment: {
|
||||
code: detailCode(response.data, response.status),
|
||||
};
|
||||
}
|
||||
return { state: "unavailable" as const, code: `http_${response.status}` };
|
||||
return {
|
||||
state: "unavailable" as const,
|
||||
code: unavailableCode(response.data, response.status),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** A 503 that names `embedding_unavailable` keeps it: the search could not
|
||||
* embed its query, which needs another fix than "try again later". */
|
||||
function unavailableCode(value: unknown, status: number): string {
|
||||
return status === 503 && detailCode(value, status) === EMBEDDING_UNAVAILABLE
|
||||
? EMBEDDING_UNAVAILABLE
|
||||
: `http_${status}`;
|
||||
}
|
||||
|
||||
export const EMBEDDING_UNAVAILABLE = "embedding_unavailable";
|
||||
|
||||
/** Core's stable `detail` code when it sent one, else the bare status. */
|
||||
function detailCode(value: unknown, status: number): string {
|
||||
const detail =
|
||||
@@ -217,7 +234,10 @@ export function createCoreContextQuery(attachment: {
|
||||
if (response.status === 404) {
|
||||
return { state: "not_found" as const, code: "http_404" };
|
||||
}
|
||||
return { state: "unavailable" as const, code: `http_${response.status}` };
|
||||
return {
|
||||
state: "unavailable" as const,
|
||||
code: unavailableCode(response.data, response.status),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,29 @@ const GRAPH_WIDTH_PATH = "/api/v1/graph-width";
|
||||
/** `memhub evolve` starts a run; `memhub settings mode <m> --yes` sets the mode. */
|
||||
const EVOLVE_PATH = "/api/v2/evolve";
|
||||
const MEMORY_MODE_PATH = "/api/v1/settings/memory-mode";
|
||||
/** `memhub settings features set|reset` sets the SDK features. */
|
||||
const MEMORY_FEATURES_PATH = "/api/v1/settings/memory-features";
|
||||
const ONBOARDING_COMPLETE_PATH = "/api/v1/onboarding/complete";
|
||||
const EMBEDDING_SWITCH_PATHS = [
|
||||
"/api/v1/embedding-switches",
|
||||
"/api/v1/embedding-switches/plan",
|
||||
] as const;
|
||||
const EMBEDDING_SWITCH_CANCEL =
|
||||
/^\/api\/v1\/embedding-switches\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/cancel$/;
|
||||
const EMBEDDING_SPACE_PATH =
|
||||
/^\/api\/v1\/embedding-spaces\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export type OperationOutcome<T> =
|
||||
| { state: "committed"; data: T }
|
||||
| { state: "rejected"; code: string; http_status: number }
|
||||
| {
|
||||
state: "rejected";
|
||||
code: string;
|
||||
http_status: number;
|
||||
/** Core's refusal body, when it carries more than the code (a switch
|
||||
* plan rides along with `embedding_switch_required`). */
|
||||
body?: unknown;
|
||||
}
|
||||
| { state: "unknown"; code: string };
|
||||
|
||||
export interface CoreOperations {
|
||||
@@ -102,6 +119,8 @@ export function createCoreOperations(attachment: {
|
||||
path !== GRAPH_WIDTH_PATH &&
|
||||
path !== EVOLVE_PATH &&
|
||||
path !== ONBOARDING_COMPLETE_PATH &&
|
||||
!(EMBEDDING_SWITCH_PATHS as readonly string[]).includes(path) &&
|
||||
!EMBEDDING_SWITCH_CANCEL.test(path) &&
|
||||
!MODEL_PROFILE_ACTIVATE.test(path)
|
||||
) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
@@ -119,11 +138,18 @@ export function createCoreOperations(attachment: {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return { state: "committed", data: response.data as T };
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
if (
|
||||
(response.status >= 400 && response.status < 500) ||
|
||||
// Core answers a refusal it can name with 503 (`sdk_reembed_unavailable`)
|
||||
// or 507 (`insufficient_disk_space`) on the switch routes.
|
||||
([503, 507].includes(response.status) &&
|
||||
(EMBEDDING_SWITCH_PATHS as readonly string[]).includes(path))
|
||||
) {
|
||||
return {
|
||||
state: "rejected",
|
||||
code: safeErrorCode(response.data, response.status),
|
||||
http_status: response.status,
|
||||
body: response.data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -138,6 +164,7 @@ export function createCoreOperations(attachment: {
|
||||
) {
|
||||
if (
|
||||
path !== MEMORY_MODE_PATH &&
|
||||
path !== MEMORY_FEATURES_PATH &&
|
||||
(!SDK_MEMORY_PATH.test(path) || path.includes("?"))
|
||||
) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
@@ -201,7 +228,11 @@ export function createCoreOperations(attachment: {
|
||||
};
|
||||
},
|
||||
async delete<T>(path: string, signal?: AbortSignal) {
|
||||
if (!SDK_MEMORY_PATH.test(path) && !MODEL_PROFILE_PATH.test(path)) {
|
||||
if (
|
||||
!SDK_MEMORY_PATH.test(path) &&
|
||||
!MODEL_PROFILE_PATH.test(path) &&
|
||||
!EMBEDDING_SPACE_PATH.test(path)
|
||||
) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
}
|
||||
let response: { status: number; data: unknown };
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { renderContextText } from "./context-search.js";
|
||||
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
|
||||
import { composeStatusReport, renderStatusText } from "./status-report.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: { create: vi.fn(() => transport), get: vi.fn() },
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const PROFILE = "44444444-4444-4444-8444-444444444444";
|
||||
const JOB = "55555555-5555-4555-8555-555555555555";
|
||||
const SPACE = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
function plan(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
needed: true,
|
||||
reason: "embedding_identity_changed",
|
||||
plan_token: "token-1",
|
||||
source: {
|
||||
space_id: "77777777-7777-4777-8777-777777777777",
|
||||
model: "bge-small-zh-v1.5",
|
||||
dims: 512,
|
||||
collection_base: "memhub_aaaaaaaaaaaaaaaa",
|
||||
vectors: 12480,
|
||||
},
|
||||
target: {
|
||||
space_id: null,
|
||||
model: "text-embedding-v3",
|
||||
dims: 1024,
|
||||
collection_base: "",
|
||||
},
|
||||
target_profile_id: PROFILE,
|
||||
target_revision: 5,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
sources: [],
|
||||
total_vectors: 12480,
|
||||
users: 2,
|
||||
graph_vectors: null,
|
||||
estimate_s: 120,
|
||||
requests: 195,
|
||||
disk_bytes_needed: 70 * 1024 * 1024,
|
||||
disk_bytes_free: 10 * 1024 * 1024 * 1024,
|
||||
retention_days: 14,
|
||||
blockers: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function job(
|
||||
state: string,
|
||||
percent: number,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
job_id: JOB,
|
||||
kind: "switch",
|
||||
state,
|
||||
source: plan().source,
|
||||
target: { ...plan().target, space_id: SPACE },
|
||||
stray_space_ids: [],
|
||||
target_profile_id: PROFILE,
|
||||
target_revision: 5,
|
||||
requested_by: "cli",
|
||||
progress: {
|
||||
phase: state === "reembedding" ? "vectors" : "prepare",
|
||||
done: Math.round((12480 * percent) / 100),
|
||||
total: 12480,
|
||||
percent,
|
||||
eta_s: 60,
|
||||
},
|
||||
cancel_requested: false,
|
||||
error_code: null,
|
||||
attempt: 1,
|
||||
created_at: "T1",
|
||||
updated_at: "T1",
|
||||
finished_at: state === "done" ? "T2" : null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue({
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("models activate with an embedding change", () => {
|
||||
it("prints the plan and exits 2 without --yes on a non-terminal", async () => {
|
||||
transport.post.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/embedding-switches/plan"
|
||||
? { status: 200, data: plan() }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
const result = await capturedRun(["models", "activate", PROFILE]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stdout).toContain("将更换向量模型");
|
||||
expect(result.stdout).toContain("bge-small-zh-v1.5(512 维)");
|
||||
expect(result.stdout).toContain("text-embedding-v3(1024 维)");
|
||||
expect(result.stdout).toContain("12,480");
|
||||
expect(result.stdout).toContain("重新运行并加上 --yes 以确认");
|
||||
expect(transport.post.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/api/v1/embedding-switches/plan",
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts the switch with --yes and emits JSON progress lines", async () => {
|
||||
transport.post.mockImplementation(async (url: string) => {
|
||||
if (url === "/api/v1/embedding-switches/plan") {
|
||||
return { status: 200, data: plan() };
|
||||
}
|
||||
if (url === "/api/v1/embedding-switches") {
|
||||
return { status: 202, data: job("preparing", 0) };
|
||||
}
|
||||
return { status: 404, data: {} };
|
||||
});
|
||||
const answers = [
|
||||
{
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
job: job("reembedding", 50),
|
||||
last_finished: null,
|
||||
},
|
||||
{
|
||||
phase: "idle",
|
||||
writes_paused: false,
|
||||
job: null,
|
||||
last_finished: job("done", 100),
|
||||
},
|
||||
];
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/embedding-switches/current"
|
||||
? { status: 200, data: { schema_version: 1, ...answers.shift() } }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
const result = await capturedRun(
|
||||
["models", "activate", PROFILE, "--yes", "--wait", "--json"],
|
||||
{ watchWait: async () => undefined },
|
||||
);
|
||||
expect(result.code).toBe(0);
|
||||
const lines = result.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(lines.map((line) => line.state)).toEqual([
|
||||
"preparing",
|
||||
"reembedding",
|
||||
"done",
|
||||
"done",
|
||||
]);
|
||||
expect(lines[1]).toMatchObject({ percent: 50, job_id: JOB });
|
||||
expect(lines.at(-1)).toMatchObject({ result: "switched" });
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/embedding-switches",
|
||||
{
|
||||
profile_id: PROFILE,
|
||||
expected_revision: 5,
|
||||
plan_token: "token-1",
|
||||
requested_by: "cli",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a plan with blockers and starts nothing", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: plan({ blockers: ["sdk_reembed_unavailable"] }),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"models",
|
||||
"activate",
|
||||
PROFILE,
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "sdk_reembed_unavailable",
|
||||
});
|
||||
expect(transport.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("says nothing is re-embedded when only generation changed", async () => {
|
||||
transport.post.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/embedding-switches/plan"
|
||||
? {
|
||||
status: 200,
|
||||
data: plan({ needed: false, reason: "same_embedding" }),
|
||||
}
|
||||
: { status: 200, data: profileResponse() },
|
||||
);
|
||||
const result = await capturedRun(["models", "activate", PROFILE]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("向量模型未变化,无需重新生成向量。");
|
||||
});
|
||||
|
||||
it("reads the plan carried by a 409 embedding_switch_required", async () => {
|
||||
transport.post.mockImplementation(async (url: string) =>
|
||||
url.endsWith("/activate")
|
||||
? {
|
||||
status: 409,
|
||||
data: { detail: "embedding_switch_required", plan: plan() },
|
||||
}
|
||||
: { status: 404, data: { detail: "Not Found" } },
|
||||
);
|
||||
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
state: "confirmation_required",
|
||||
code: "embedding_switch_required",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("models switch and spaces", () => {
|
||||
it("reports no switch, then cancels the running one", async () => {
|
||||
transport.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
schema_version: 1,
|
||||
phase: "idle",
|
||||
writes_paused: false,
|
||||
job: null,
|
||||
last_finished: null,
|
||||
},
|
||||
});
|
||||
const idle = await capturedRun(["models", "switch", "status"]);
|
||||
expect(idle.code).toBe(0);
|
||||
expect(idle.stdout).toContain("没有进行中的向量模型切换");
|
||||
|
||||
transport.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
job: job("reembedding", 20),
|
||||
last_finished: null,
|
||||
},
|
||||
});
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: job("reembedding", 20, { cancel_requested: true }),
|
||||
});
|
||||
const cancelled = await capturedRun([
|
||||
"models",
|
||||
"switch",
|
||||
"cancel",
|
||||
"--json",
|
||||
]);
|
||||
expect(cancelled.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
`/api/v1/embedding-switches/${JOB}/cancel`,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(cancelled.stdout)).toMatchObject({
|
||||
cancel_requested: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists spaces and needs --yes before cleaning one up", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
schema_version: 1,
|
||||
live_space_id: "x",
|
||||
spaces: [
|
||||
{
|
||||
space_id: SPACE,
|
||||
state: "retired",
|
||||
model: "bge-small-zh-v1.5",
|
||||
dims: 512,
|
||||
vectors: 10,
|
||||
keep_until: "2026-11-15T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
space_id: "88888888-8888-4888-8888-888888888888",
|
||||
state: "dropped",
|
||||
model: "m",
|
||||
dims: 8,
|
||||
},
|
||||
],
|
||||
scanned: [],
|
||||
scan_error: null,
|
||||
},
|
||||
});
|
||||
const listed = await capturedRun(["models", "spaces"]);
|
||||
expect(listed.code).toBe(0);
|
||||
expect(listed.stdout).toContain("已停用");
|
||||
expect(listed.stdout).toContain("保留到 2026-11-15");
|
||||
expect(listed.stdout).not.toContain("88888888");
|
||||
|
||||
const preview = await capturedRun(["models", "spaces", "--cleanup", SPACE]);
|
||||
expect(preview.code).toBe(0);
|
||||
expect(preview.stdout).toContain("--yes");
|
||||
expect(transport.delete).not.toHaveBeenCalled();
|
||||
|
||||
transport.delete.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { schema_version: 1 },
|
||||
});
|
||||
const dropped = await capturedRun([
|
||||
"models",
|
||||
"spaces",
|
||||
"--cleanup",
|
||||
SPACE,
|
||||
"--yes",
|
||||
]);
|
||||
expect(dropped.code).toBe(0);
|
||||
expect(transport.delete).toHaveBeenCalledWith(
|
||||
`/api/v1/embedding-spaces/${SPACE}`,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status and search wording", () => {
|
||||
it("shows the switch line in memhub status", async () => {
|
||||
const reader = async (path: string) =>
|
||||
path === "/api/v1/memory-processing"
|
||||
? {
|
||||
state: "ok" as const,
|
||||
data: {
|
||||
pending: 3,
|
||||
fresh_pending: 1,
|
||||
backlog_pending: 2,
|
||||
outside_window_pending: 0,
|
||||
running: 0,
|
||||
waiting_for_model: 0,
|
||||
completed: 9,
|
||||
failed: 0,
|
||||
embedding_switch: {
|
||||
job_id: JOB,
|
||||
kind: "switch",
|
||||
state: "reembedding",
|
||||
percent: 50,
|
||||
eta_s: 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
: { state: "unavailable" as const, code: "http_503" };
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/synthetic/data",
|
||||
runtime: {
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
} as never,
|
||||
background: null,
|
||||
reader: reader as never,
|
||||
});
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain("向量模型:切换中 50%,记忆整理已暂停");
|
||||
expect(
|
||||
describeEmbeddingSwitchSummary({
|
||||
kind: "merge",
|
||||
state: "reembedding",
|
||||
percent: 30,
|
||||
}),
|
||||
).toBe("正在合并旧向量数据 30%,记忆整理已暂停");
|
||||
expect(
|
||||
describeEmbeddingSwitchSummary({
|
||||
kind: "merge",
|
||||
state: "scanning",
|
||||
percent: 0,
|
||||
}),
|
||||
).toBe("正在检查向量数据…");
|
||||
});
|
||||
|
||||
it("names an unavailable embedding service instead of 'try later'", () => {
|
||||
expect(
|
||||
renderContextText({
|
||||
schema_version: 2,
|
||||
product: "memhub",
|
||||
state: "unavailable",
|
||||
code: "embedding_unavailable",
|
||||
items: [],
|
||||
}),
|
||||
).toContain("向量服务不可用");
|
||||
});
|
||||
});
|
||||
|
||||
function profileResponse() {
|
||||
return {
|
||||
profile_id: PROFILE,
|
||||
name: "Synthetic",
|
||||
provider_kind: "openai_compatible",
|
||||
base_url: "https://example.invalid/v1",
|
||||
model: "synthetic-model-2",
|
||||
embedding_model: "text-embedding-v3",
|
||||
embedding_provider_kind: "openai_compatible",
|
||||
embedding_base_url: "https://example.invalid/v1",
|
||||
embedding_dimensions: 1024,
|
||||
has_api_key: true,
|
||||
credential_state: "available",
|
||||
timeout_seconds: 30,
|
||||
max_concurrency: 1,
|
||||
context_window: null,
|
||||
disable_thinking: true,
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
structured_output: true,
|
||||
embedding_available: true,
|
||||
},
|
||||
revision: 5,
|
||||
is_active: true,
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
updated_at: "2026-09-19T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function capturedRun(
|
||||
arguments_: string[],
|
||||
controls: Parameters<typeof run>[2] = {},
|
||||
) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return {
|
||||
code: await run(
|
||||
arguments_,
|
||||
{ ONE_MEMORY_DATA_DIR: "/synthetic/data" },
|
||||
{ stdinIsTTY: false, ...controls },
|
||||
),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
/**
|
||||
* Switching the embedding model from the CLI: `models activate` when the
|
||||
* embedding changes, `models switch status|cancel`, `models spaces`.
|
||||
*
|
||||
* Core owns every decision (`/api/v1/embedding-switches*`, contract
|
||||
* `docs/contracts/embedding-switch-v1.md`). A change of embedding model or
|
||||
* width is never applied by activation alone: Core plans a switch, the user
|
||||
* confirms it, Core re-embeds every memory into a new space and only then
|
||||
* makes it live. The CLI shows the plan, asks, starts the job and follows it.
|
||||
*/
|
||||
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type {
|
||||
CoreDeleteOperations,
|
||||
CoreOperations,
|
||||
OperationOutcome,
|
||||
} from "./core-operations.js";
|
||||
import { EXIT_FAILED, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
||||
import {
|
||||
activateModelProfile,
|
||||
type ModelWriteDocument,
|
||||
modelProfileResult,
|
||||
renderModelWriteText,
|
||||
} from "./model-write-flows.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const CODE = /^[a-z0-9_.:-]{1,128}$/;
|
||||
const TERMINAL = new Set(["done", "rolled_back"]);
|
||||
const POLL_MS = 1_000;
|
||||
|
||||
export interface SpaceSummary {
|
||||
space_id: string | null;
|
||||
model: string | null;
|
||||
dims: number | null;
|
||||
collection_base: string;
|
||||
vectors?: number | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchPlan {
|
||||
needed: boolean;
|
||||
reason: string;
|
||||
plan_token: string;
|
||||
source: SpaceSummary;
|
||||
target: SpaceSummary;
|
||||
target_profile_id: string;
|
||||
target_revision: number;
|
||||
target_provider_kind: string;
|
||||
target_local: boolean;
|
||||
total_vectors: number;
|
||||
users: number | null;
|
||||
graph_vectors: number | null;
|
||||
estimate_s: number | null;
|
||||
requests: number | null;
|
||||
disk_bytes_needed: number;
|
||||
disk_bytes_free: number | null;
|
||||
retention_days: number;
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
export interface SwitchProgress {
|
||||
phase: string;
|
||||
done: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
eta_s: number | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchJob {
|
||||
job_id: string;
|
||||
kind: "switch" | "merge";
|
||||
state: string;
|
||||
source: SpaceSummary;
|
||||
target: SpaceSummary;
|
||||
progress: SwitchProgress;
|
||||
cancel_requested: boolean;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface SwitchCurrent {
|
||||
phase: "idle" | "scanning" | "running";
|
||||
writes_paused: boolean;
|
||||
job: EmbeddingSwitchJob | null;
|
||||
last_finished: EmbeddingSwitchJob | null;
|
||||
}
|
||||
|
||||
export interface SwitchIo {
|
||||
reader: CoreReader;
|
||||
operations: CoreOperations & CoreDeleteOperations;
|
||||
emit: (text: string) => void;
|
||||
asJson: boolean;
|
||||
/** A terminal is attached: may prompt, and follows progress by default. */
|
||||
interactive: boolean;
|
||||
confirm?: (question: string) => Promise<boolean>;
|
||||
wait?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ActivateOptions {
|
||||
confirmed: boolean;
|
||||
/** `--wait` / `--no-wait`; undefined follows on a terminal only. */
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing: bounded projections of Core's answers, never passthrough.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function int(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function str(value: unknown, max = 256): string | null {
|
||||
return typeof value === "string" && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
function space(value: unknown): SpaceSummary | null {
|
||||
const item = record(value);
|
||||
if (!item) return null;
|
||||
return {
|
||||
space_id: str(item.space_id, 64),
|
||||
model: str(item.model),
|
||||
dims: int(item.dims),
|
||||
collection_base: str(item.collection_base) ?? "",
|
||||
vectors: int(item.vectors),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePlan(value: unknown): EmbeddingSwitchPlan | null {
|
||||
const plan = record(value);
|
||||
if (!plan || typeof plan.needed !== "boolean") return null;
|
||||
const source = space(plan.source);
|
||||
const target = space(plan.target);
|
||||
const token = str(plan.plan_token, 128);
|
||||
const profile = str(plan.target_profile_id, 128);
|
||||
const revision = int(plan.target_revision);
|
||||
if (!source || !target || !token || !profile || revision === null) {
|
||||
return null;
|
||||
}
|
||||
const blockers = Array.isArray(plan.blockers)
|
||||
? plan.blockers.filter(
|
||||
(item): item is string => typeof item === "string" && CODE.test(item),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
needed: plan.needed,
|
||||
reason: str(plan.reason, 64) ?? "",
|
||||
plan_token: token,
|
||||
source,
|
||||
target,
|
||||
target_profile_id: profile,
|
||||
target_revision: revision,
|
||||
target_provider_kind: str(plan.target_provider_kind, 64) ?? "",
|
||||
target_local: plan.target_local === true,
|
||||
total_vectors: int(plan.total_vectors) ?? 0,
|
||||
users: int(plan.users),
|
||||
graph_vectors: int(plan.graph_vectors),
|
||||
estimate_s: int(plan.estimate_s),
|
||||
requests: int(plan.requests),
|
||||
disk_bytes_needed: int(plan.disk_bytes_needed) ?? 0,
|
||||
disk_bytes_free: int(plan.disk_bytes_free),
|
||||
retention_days: int(plan.retention_days) ?? 14,
|
||||
blockers,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJob(value: unknown): EmbeddingSwitchJob | null {
|
||||
const job = record(value);
|
||||
if (!job) return null;
|
||||
const jobId = str(job.job_id, 64);
|
||||
const state = str(job.state, 32);
|
||||
const source = space(job.source);
|
||||
const target = space(job.target);
|
||||
const progress = record(job.progress) ?? {};
|
||||
if (!jobId || !state || !source || !target) return null;
|
||||
if (job.kind !== "switch" && job.kind !== "merge") return null;
|
||||
const percent =
|
||||
typeof progress.percent === "number" && Number.isFinite(progress.percent)
|
||||
? Math.min(100, Math.max(0, progress.percent))
|
||||
: 0;
|
||||
return {
|
||||
job_id: jobId,
|
||||
kind: job.kind,
|
||||
state,
|
||||
source,
|
||||
target,
|
||||
progress: {
|
||||
phase: str(progress.phase, 32) ?? "prepare",
|
||||
done: int(progress.done) ?? 0,
|
||||
total: int(progress.total) ?? 0,
|
||||
percent,
|
||||
eta_s: int(progress.eta_s),
|
||||
},
|
||||
cancel_requested: job.cancel_requested === true,
|
||||
error_code:
|
||||
typeof job.error_code === "string" && CODE.test(job.error_code)
|
||||
? job.error_code
|
||||
: null,
|
||||
finished_at: str(job.finished_at, 64),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCurrent(value: unknown): SwitchCurrent | null {
|
||||
const current = record(value);
|
||||
if (!current) return null;
|
||||
const phase = current.phase;
|
||||
if (phase !== "idle" && phase !== "scanning" && phase !== "running") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
phase,
|
||||
writes_paused: current.writes_paused === true,
|
||||
job: current.job == null ? null : parseJob(current.job),
|
||||
last_finished:
|
||||
current.last_finished == null ? null : parseJob(current.last_finished),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wording
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BLOCKER_TEXT: Record<string, string> = {
|
||||
embedding_switch_running: "上一次向量模型切换还在进行,请等待完成或先取消。",
|
||||
embedding_probe_required: "新配置还没有通过探测;先运行 memhub models test。",
|
||||
sdk_reembed_unavailable:
|
||||
"当前运行时的 SDK 不支持重新生成向量,请先升级 MemHub。",
|
||||
embedding_switch_requires_migration:
|
||||
"还有旧版本的向量数据没有迁移完,请先完成升级迁移。",
|
||||
embedding_dimensions_unknown:
|
||||
"新配置的向量维度未知;先探测或在配置里写明 embedding_dimensions。",
|
||||
insufficient_disk_space: "磁盘空间不足,无法容纳新旧两份向量数据。",
|
||||
};
|
||||
|
||||
const FAILURE_TEXT: Record<string, string> = {
|
||||
writes_did_not_drain: "等待正在进行的记忆整理结束超时",
|
||||
embedding_service_failed: "向量服务连续请求失败",
|
||||
embedding_dimensions_mismatch: "向量服务返回的维度与探测到的不一致",
|
||||
embedding_verification_failed: "新向量没有通过校验",
|
||||
target_profile_changed: "切换期间目标配置被修改",
|
||||
embedding_switch_cancelled: "已取消",
|
||||
model_secret_unavailable: "读不到向量服务的 API Key",
|
||||
secure_storage_unavailable: "系统钥匙串不可用",
|
||||
embedding_switch_clients_busy: "记忆图谱一直被占用,无法重新生成",
|
||||
};
|
||||
|
||||
const STATE_TEXT: Record<string, string> = {
|
||||
preparing: "准备中(等待记忆整理暂停)",
|
||||
reembedding: "重新生成向量",
|
||||
verifying: "校验新向量",
|
||||
committing: "正式切换",
|
||||
failed: "正在回滚",
|
||||
done: "已完成",
|
||||
rolled_back: "已回滚",
|
||||
};
|
||||
|
||||
export function blockerText(code: string): string {
|
||||
return BLOCKER_TEXT[code] ?? code;
|
||||
}
|
||||
|
||||
function spaceLabel(item: SpaceSummary, local?: boolean): string {
|
||||
const dims = item.dims === null ? "维度未知" : `${item.dims} 维`;
|
||||
return `${item.model ?? "未知模型"}(${dims}${local ? ",本地" : ""})`;
|
||||
}
|
||||
|
||||
function duration(seconds: number | null): string {
|
||||
if (seconds === null) return "未知";
|
||||
if (seconds < 60) return `${Math.max(1, seconds)} 秒`;
|
||||
return `${Math.round(seconds / 60)} 分钟`;
|
||||
}
|
||||
|
||||
function megabytes(bytes: number | null): string {
|
||||
return bytes === null ? "未知" : `${Math.ceil(bytes / (1024 * 1024))} MB`;
|
||||
}
|
||||
|
||||
export function renderPlanText(plan: EmbeddingSwitchPlan): string {
|
||||
const lines = [
|
||||
`将更换向量模型:${spaceLabel(plan.source)} → ${spaceLabel(plan.target, plan.target_local)}`,
|
||||
`需要用新模型重新生成 ${plan.total_vectors.toLocaleString("en-US")} 条向量` +
|
||||
`${plan.users === null ? "" : `(${plan.users} 个用户)`}` +
|
||||
`,预计约 ${duration(plan.estimate_s)}` +
|
||||
`${plan.requests === null ? ",在本机计算,不产生费用" : `,约 ${plan.requests} 次请求`}` +
|
||||
`,额外磁盘约 ${megabytes(plan.disk_bytes_needed)}。`,
|
||||
];
|
||||
if (plan.graph_vectors) {
|
||||
lines.push(`记忆图谱另有 ${plan.graph_vectors} 条向量一并重新生成。`);
|
||||
}
|
||||
if (
|
||||
plan.source.dims !== null &&
|
||||
plan.source.dims === plan.target.dims &&
|
||||
plan.source.model !== plan.target.model
|
||||
) {
|
||||
lines.push("新旧模型维度相同,但向量不能通用,仍需重新生成。");
|
||||
}
|
||||
lines.push(
|
||||
"切换期间记忆整理暂停,检索继续使用旧模型;完成并校验通过后才会生效。",
|
||||
`旧的向量数据保留 ${plan.retention_days} 天,可用 memhub models spaces 清理。`,
|
||||
);
|
||||
for (const blocker of plan.blockers) {
|
||||
lines.push(`无法开始:${blockerText(blocker)}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function renderProgressLine(job: EmbeddingSwitchJob): string {
|
||||
const what = job.kind === "merge" ? "正在合并旧向量数据" : "正在切换向量模型";
|
||||
const state = STATE_TEXT[job.state] ?? job.state;
|
||||
if (job.state === "reembedding" && job.progress.total > 0) {
|
||||
const eta =
|
||||
job.progress.eta_s === null
|
||||
? ""
|
||||
: ` 预计还需 ${duration(job.progress.eta_s)}`;
|
||||
return `${what}:${state} ${job.progress.done.toLocaleString("en-US")}/${job.progress.total.toLocaleString("en-US")} ${Math.floor(job.progress.percent)}%${eta}`;
|
||||
}
|
||||
return `${what}:${state}`;
|
||||
}
|
||||
|
||||
export function renderOutcomeText(job: EmbeddingSwitchJob): string {
|
||||
if (job.state === "done") {
|
||||
return job.kind === "merge"
|
||||
? "旧向量数据已合并,记忆整理已恢复。\n"
|
||||
: `已切换到 ${job.target.model ?? "新模型"}。\n`;
|
||||
}
|
||||
const code = job.error_code ?? "embedding_switch_failed";
|
||||
if (code === "embedding_switch_cancelled") {
|
||||
return `已取消切换,继续使用 ${job.source.model ?? "旧模型"}。\n`;
|
||||
}
|
||||
return `切换未完成,已恢复使用 ${job.source.model ?? "旧模型"},数据未受影响。原因:${FAILURE_TEXT[code] ?? code}(${code})\n`;
|
||||
}
|
||||
|
||||
/** One line for `memhub status`, from `memory-processing.embedding_switch`. */
|
||||
export function describeEmbeddingSwitchSummary(value: unknown): string | null {
|
||||
const summary = record(value);
|
||||
if (!summary) return null;
|
||||
const percent =
|
||||
typeof summary.percent === "number" && Number.isFinite(summary.percent)
|
||||
? Math.floor(summary.percent)
|
||||
: 0;
|
||||
if (summary.state === "scanning") return "正在检查向量数据…";
|
||||
if (summary.kind === "merge") {
|
||||
return `正在合并旧向量数据 ${percent}%,记忆整理已暂停`;
|
||||
}
|
||||
if (summary.state === "failed") return "切换未完成,正在恢复旧向量模型";
|
||||
return `切换中 ${percent}%,记忆整理已暂停`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function document(
|
||||
command: string,
|
||||
fields: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return { schema_version: 1, product: PRODUCT, command, ...fields };
|
||||
}
|
||||
|
||||
function emitDocument(io: SwitchIo, value: Record<string, unknown>): void {
|
||||
io.emit(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function requestPlan(
|
||||
profileId: string,
|
||||
io: SwitchIo,
|
||||
): Promise<
|
||||
| { state: "plan"; plan: EmbeddingSwitchPlan }
|
||||
| { state: "unsupported" }
|
||||
| { state: "failed"; code: string }
|
||||
> {
|
||||
const outcome = await io.operations.post<unknown>(
|
||||
"/api/v1/embedding-switches/plan",
|
||||
{ profile_id: profileId },
|
||||
);
|
||||
if (outcome.state === "committed") {
|
||||
const plan = parsePlan(outcome.data);
|
||||
// Not a plan: activation itself still refuses an embedding change.
|
||||
return plan ? { state: "plan", plan } : { state: "unsupported" };
|
||||
}
|
||||
if (outcome.state === "rejected") {
|
||||
// A Core before the registry has no plan route: activation as before.
|
||||
if (outcome.http_status === 404 && outcome.code === "http_404") {
|
||||
return { state: "unsupported" };
|
||||
}
|
||||
const plan = parsePlan(record(outcome.body)?.plan);
|
||||
if (plan) return { state: "plan", plan };
|
||||
return { state: "failed", code: outcome.code };
|
||||
}
|
||||
return { state: "failed", code: outcome.code };
|
||||
}
|
||||
|
||||
function emitActivation(io: SwitchIo, result: ModelWriteDocument): number {
|
||||
io.emit(
|
||||
io.asJson ? `${JSON.stringify(result)}\n` : renderModelWriteText(result),
|
||||
);
|
||||
return result.state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* `memhub models activate <id> [--yes] [--wait|--no-wait] [--json]`.
|
||||
*
|
||||
* Asks Core for the plan first. No embedding change: activate as before.
|
||||
* An embedding change: show the plan, confirm (`--yes`, or a prompt on a
|
||||
* terminal; exit 2 otherwise), start the switch and follow it.
|
||||
*/
|
||||
export async function activateWithEmbeddingSwitch(
|
||||
profileId: string,
|
||||
options: ActivateOptions,
|
||||
io: SwitchIo,
|
||||
): Promise<number> {
|
||||
if (!UUID.test(profileId)) {
|
||||
return emitActivation(
|
||||
io,
|
||||
await activateModelProfile(profileId, io.operations),
|
||||
);
|
||||
}
|
||||
const planned = await requestPlan(profileId, io);
|
||||
if (planned.state === "unsupported") {
|
||||
const activated = await activateOrPlan(profileId, io);
|
||||
return activated.state === "plan"
|
||||
? confirmAndSwitch(activated.plan, options, io)
|
||||
: emitActivation(io, activated.result);
|
||||
}
|
||||
if (planned.state === "failed") {
|
||||
return reject(io, "models activate", planned.code);
|
||||
}
|
||||
const plan = planned.plan;
|
||||
if (!plan.needed) {
|
||||
if (!io.asJson && plan.reason === "same_embedding") {
|
||||
io.emit("向量模型未变化,无需重新生成向量。\n");
|
||||
}
|
||||
const activated = await activateOrPlan(profileId, io);
|
||||
if (activated.state === "plan") {
|
||||
return confirmAndSwitch(activated.plan, options, io);
|
||||
}
|
||||
return emitActivation(io, activated.result);
|
||||
}
|
||||
return confirmAndSwitch(plan, options, io);
|
||||
}
|
||||
|
||||
async function activateOrPlan(
|
||||
profileId: string,
|
||||
io: SwitchIo,
|
||||
): Promise<
|
||||
| { state: "plan"; plan: EmbeddingSwitchPlan }
|
||||
| { state: "activated"; result: ModelWriteDocument }
|
||||
> {
|
||||
const result: OperationOutcome<unknown> = await io.operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${encodeURIComponent(profileId)}/activate`,
|
||||
{},
|
||||
);
|
||||
if (
|
||||
result.state === "rejected" &&
|
||||
result.code === "embedding_switch_required"
|
||||
) {
|
||||
const plan = parsePlan(record(result.body)?.plan);
|
||||
if (plan) return { state: "plan", plan };
|
||||
}
|
||||
return { state: "activated", result: modelProfileResult(profileId, result) };
|
||||
}
|
||||
|
||||
function reject(io: SwitchIo, command: string, code: string): number {
|
||||
if (io.asJson) {
|
||||
emitDocument(io, document(command, { state: "rejected", code }));
|
||||
} else {
|
||||
io.emit(`${command} 未完成:${blockerText(code)}(${code})\n`);
|
||||
}
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
|
||||
async function confirmAndSwitch(
|
||||
plan: EmbeddingSwitchPlan,
|
||||
options: ActivateOptions,
|
||||
io: SwitchIo,
|
||||
): Promise<number> {
|
||||
if (plan.blockers.length > 0) {
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models activate", {
|
||||
state: "blocked",
|
||||
code: plan.blockers[0],
|
||||
plan,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
io.emit(renderPlanText(plan));
|
||||
}
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
if (!options.confirmed) {
|
||||
const ask = io.interactive && !io.asJson ? io.confirm : undefined;
|
||||
if (ask === undefined) {
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models activate", {
|
||||
state: "confirmation_required",
|
||||
code: "embedding_switch_required",
|
||||
plan,
|
||||
next_step: `${PRODUCT} models activate ${plan.target_profile_id} --yes`,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
io.emit(`${renderPlanText(plan)}重新运行并加上 --yes 以确认\n`);
|
||||
}
|
||||
return EXIT_USAGE;
|
||||
}
|
||||
io.emit(renderPlanText(plan));
|
||||
if (!(await ask("继续?[y/N] "))) {
|
||||
io.emit("未开始切换,仍在使用原来的向量模型。\n");
|
||||
return EXIT_OK;
|
||||
}
|
||||
}
|
||||
const started = await io.operations.post<unknown>(
|
||||
"/api/v1/embedding-switches",
|
||||
{
|
||||
profile_id: plan.target_profile_id,
|
||||
expected_revision: plan.target_revision,
|
||||
plan_token: plan.plan_token,
|
||||
requested_by: "cli",
|
||||
},
|
||||
);
|
||||
if (started.state !== "committed") {
|
||||
return reject(io, "models activate", started.code);
|
||||
}
|
||||
const job = parseJob(started.data);
|
||||
if (!job) return reject(io, "models activate", "embedding_switch_invalid");
|
||||
const follow = options.follow ?? io.interactive;
|
||||
if (!follow) {
|
||||
if (io.asJson) {
|
||||
emitDocument(io, document("models activate", progressFields(job)));
|
||||
} else {
|
||||
io.emit(
|
||||
`已开始切换向量模型(${job.job_id})。用 memhub models switch status 查看进度。\n`,
|
||||
);
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
return followJob(job, io, "models activate");
|
||||
}
|
||||
|
||||
function progressFields(job: EmbeddingSwitchJob): Record<string, unknown> {
|
||||
return {
|
||||
state: job.state,
|
||||
job_id: job.job_id,
|
||||
kind: job.kind,
|
||||
phase: job.progress.phase,
|
||||
percent: job.progress.percent,
|
||||
done: job.progress.done,
|
||||
total: job.progress.total,
|
||||
eta_s: job.progress.eta_s,
|
||||
...(job.error_code ? { code: job.error_code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readCurrent(io: SwitchIo): Promise<SwitchCurrent | null> {
|
||||
const outcome = await io.reader<unknown>(
|
||||
"/api/v1/embedding-switches/current",
|
||||
);
|
||||
return outcome.state === "ok" ? parseCurrent(outcome.data) : null;
|
||||
}
|
||||
|
||||
/** Poll `current` until the job ends or the user stops watching. */
|
||||
async function followJob(
|
||||
started: EmbeddingSwitchJob,
|
||||
io: SwitchIo,
|
||||
command: string,
|
||||
): Promise<number> {
|
||||
const wait = io.wait ?? defaultWait;
|
||||
const signal = io.signal ?? new AbortController().signal;
|
||||
let last = "";
|
||||
let job = started;
|
||||
const show = (value: EmbeddingSwitchJob) => {
|
||||
const line = io.asJson
|
||||
? JSON.stringify(document(command, progressFields(value)))
|
||||
: renderProgressLine(value);
|
||||
if (line !== last) {
|
||||
io.emit(`${line}\n`);
|
||||
last = line;
|
||||
}
|
||||
};
|
||||
show(job);
|
||||
if (!io.asJson) {
|
||||
io.emit(
|
||||
"(Ctrl-C 只停止显示;用 `memhub models switch cancel` 取消切换)\n",
|
||||
);
|
||||
}
|
||||
while (!TERMINAL.has(job.state)) {
|
||||
try {
|
||||
await wait(POLL_MS, signal);
|
||||
} catch {
|
||||
// Aborted below.
|
||||
}
|
||||
if (signal.aborted) {
|
||||
if (!io.asJson) {
|
||||
io.emit(
|
||||
"已停止显示进度;切换仍在后台进行。用 `memhub models switch cancel` 取消。\n",
|
||||
);
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
const current = await readCurrent(io);
|
||||
if (current === null) continue;
|
||||
const next =
|
||||
current.job?.job_id === job.job_id
|
||||
? current.job
|
||||
: current.last_finished?.job_id === job.job_id
|
||||
? current.last_finished
|
||||
: null;
|
||||
if (next === null) continue;
|
||||
job = next;
|
||||
show(job);
|
||||
}
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document(command, {
|
||||
...progressFields(job),
|
||||
result: job.state === "done" ? "switched" : "rolled_back",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
io.emit(renderOutcomeText(job));
|
||||
}
|
||||
return job.state === "done" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
|
||||
function defaultWait(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** `memhub models switch status [--json]`. */
|
||||
export async function switchStatus(io: SwitchIo): Promise<number> {
|
||||
const outcome = await io.reader<unknown>(
|
||||
"/api/v1/embedding-switches/current",
|
||||
);
|
||||
if (outcome.state !== "ok")
|
||||
return reject(io, "models switch status", outcome.code);
|
||||
const current = parseCurrent(outcome.data);
|
||||
if (!current) {
|
||||
return reject(io, "models switch status", "embedding_switch_invalid");
|
||||
}
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models switch status", {
|
||||
state: current.phase,
|
||||
writes_paused: current.writes_paused,
|
||||
job: current.job ? progressFields(current.job) : null,
|
||||
last_finished: current.last_finished
|
||||
? progressFields(current.last_finished)
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
return EXIT_OK;
|
||||
}
|
||||
if (current.job) {
|
||||
io.emit(
|
||||
`${renderProgressLine(current.job)}\n记忆整理已暂停,检索不受影响。\n`,
|
||||
);
|
||||
} else if (current.phase === "scanning") {
|
||||
io.emit("正在检查向量数据…\n");
|
||||
} else if (current.last_finished) {
|
||||
io.emit(
|
||||
`没有进行中的向量模型切换。上一次:${renderOutcomeText(current.last_finished)}`,
|
||||
);
|
||||
} else {
|
||||
io.emit("没有进行中的向量模型切换。\n");
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
/** `memhub models switch cancel [--json]`: cancel the running job, if any. */
|
||||
export async function switchCancel(io: SwitchIo): Promise<number> {
|
||||
const current = await readCurrent(io);
|
||||
if (current === null) {
|
||||
return reject(io, "models switch cancel", "embedding_switch_unavailable");
|
||||
}
|
||||
if (!current.job) {
|
||||
return reject(io, "models switch cancel", "embedding_switch_not_found");
|
||||
}
|
||||
const outcome = await io.operations.post<unknown>(
|
||||
`/api/v1/embedding-switches/${current.job.job_id}/cancel`,
|
||||
{},
|
||||
);
|
||||
if (outcome.state !== "committed") {
|
||||
return reject(io, "models switch cancel", outcome.code);
|
||||
}
|
||||
const job = parseJob(outcome.data) ?? current.job;
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models switch cancel", {
|
||||
...progressFields(job),
|
||||
cancel_requested: true,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
io.emit("已请求取消;当前批次完成后回滚,继续使用原来的向量模型。\n");
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
interface SpaceRow {
|
||||
space_id: string;
|
||||
state: string;
|
||||
model: string | null;
|
||||
dims: number | null;
|
||||
vectors: number | null;
|
||||
keep_until: string | null;
|
||||
}
|
||||
|
||||
function spaceRows(value: unknown): SpaceRow[] | null {
|
||||
const body = record(value);
|
||||
if (!body || !Array.isArray(body.spaces)) return null;
|
||||
const rows: SpaceRow[] = [];
|
||||
for (const item of body.spaces) {
|
||||
const row = record(item);
|
||||
const spaceId = str(row?.space_id, 64);
|
||||
const state = str(row?.state, 16);
|
||||
if (!row || !spaceId || !state) return null;
|
||||
if (state === "dropped") continue;
|
||||
rows.push({
|
||||
space_id: spaceId,
|
||||
state,
|
||||
model: str(row.model),
|
||||
dims: int(row.dims) ?? int(row.name_dims),
|
||||
vectors: int(row.vectors),
|
||||
keep_until: str(row.keep_until, 64),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const SPACE_STATE: Record<string, string> = {
|
||||
live: "使用中",
|
||||
building: "正在生成",
|
||||
stray: "待合并",
|
||||
retired: "已停用",
|
||||
};
|
||||
|
||||
/** `memhub models spaces [--json]`. */
|
||||
export async function listSpaces(io: SwitchIo): Promise<number> {
|
||||
const outcome = await io.reader<unknown>("/api/v1/embedding-spaces");
|
||||
if (outcome.state !== "ok") return reject(io, "models spaces", outcome.code);
|
||||
const rows = spaceRows(outcome.data);
|
||||
if (!rows) return reject(io, "models spaces", "embedding_spaces_invalid");
|
||||
const body = record(outcome.data) ?? {};
|
||||
const unregistered = Array.isArray(body.scanned)
|
||||
? body.scanned
|
||||
.map(record)
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
item !== null && item.state === "unregistered",
|
||||
)
|
||||
.map((item) => ({
|
||||
collection_base: str(item.collection_base) ?? "",
|
||||
vectors: int(item.vectors) ?? 0,
|
||||
}))
|
||||
: [];
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models spaces", {
|
||||
state: "ok",
|
||||
spaces: rows,
|
||||
unregistered,
|
||||
scan_error:
|
||||
typeof body.scan_error === "string" && CODE.test(body.scan_error)
|
||||
? body.scan_error
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
return EXIT_OK;
|
||||
}
|
||||
const lines = ["向量空间:"];
|
||||
for (const row of rows) {
|
||||
const dims = row.dims === null ? "维度未知" : `${row.dims} 维`;
|
||||
const keep =
|
||||
row.state === "retired" && row.keep_until
|
||||
? `,保留到 ${row.keep_until.slice(0, 10)}`
|
||||
: "";
|
||||
lines.push(
|
||||
` ${row.space_id} ${SPACE_STATE[row.state] ?? row.state} ${row.model ?? "未知模型"}(${dims}) ${row.vectors ?? "?"} 条${keep}`,
|
||||
);
|
||||
}
|
||||
for (const item of unregistered) {
|
||||
lines.push(
|
||||
` (未登记)${item.collection_base} ${item.vectors} 条,下次启动时合并`,
|
||||
);
|
||||
}
|
||||
if (rows.some((row) => row.state === "retired")) {
|
||||
lines.push(
|
||||
"清理已停用的空间:memhub models spaces --cleanup <space-id> --yes",
|
||||
);
|
||||
}
|
||||
io.emit(`${lines.join("\n")}\n`);
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
/** `memhub models spaces --cleanup <space-id> --yes`: drop a retired space now. */
|
||||
export async function cleanupSpace(
|
||||
spaceId: string,
|
||||
io: SwitchIo,
|
||||
): Promise<number> {
|
||||
if (!UUID.test(spaceId)) {
|
||||
return reject(io, "models spaces", "embedding_space_not_found");
|
||||
}
|
||||
const outcome = await io.operations.delete<unknown>(
|
||||
`/api/v1/embedding-spaces/${spaceId}`,
|
||||
);
|
||||
if (outcome.state !== "committed") {
|
||||
return reject(io, "models spaces", outcome.code);
|
||||
}
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models spaces", { state: "dropped", space_id: spaceId }),
|
||||
);
|
||||
} else {
|
||||
io.emit(`已清理旧向量数据:${spaceId}\n`);
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
@@ -510,15 +510,98 @@ describe("models add", () => {
|
||||
expect(result.stdout + result.stderr).not.toContain("SYNTHETIC_LOOSE_KEY");
|
||||
});
|
||||
|
||||
it("activates the new profile with --activate", async () => {
|
||||
it("probes the new profile before activating it with --activate", async () => {
|
||||
const configFile = await modelConfigFile();
|
||||
transport.post.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/model-profiles"
|
||||
? { status: 200, data: PROFILE }
|
||||
: url === "/api/v1/model-profiles/profile-1/activate"
|
||||
? { status: 200, data: { ...PROFILE, is_active: true } }
|
||||
: { status: 404, data: {} },
|
||||
let probeId: string | undefined;
|
||||
transport.get.mockImplementation(async (url: string) => {
|
||||
if (url === "/api/v1/model-profiles") {
|
||||
return { status: 200, data: [PROBE_PROFILE] };
|
||||
}
|
||||
const match = /^\/api\/v1\/model-probe-operations\/(.+)$/.exec(url);
|
||||
if (match) {
|
||||
return {
|
||||
status: 200,
|
||||
data:
|
||||
probeId === undefined
|
||||
? probeNotFound(match[1])
|
||||
: probeReceiptPassing(match[1]),
|
||||
};
|
||||
}
|
||||
return { status: 404, data: {} };
|
||||
});
|
||||
transport.post.mockImplementation(async (url: string, body: unknown) => {
|
||||
if (url === "/api/v1/model-profiles") {
|
||||
return { status: 200, data: PROBE_PROFILE };
|
||||
}
|
||||
if (url === `/api/v1/model-profiles/${PROBE_PROFILE.profile_id}/probe`) {
|
||||
probeId = (body as { operation_id: string }).operation_id;
|
||||
return { status: 200, data: probePending(probeId) };
|
||||
}
|
||||
if (url === "/api/v1/embedding-switches/plan") {
|
||||
return { status: 200, data: samePlan() };
|
||||
}
|
||||
if (
|
||||
url === `/api/v1/model-profiles/${PROBE_PROFILE.profile_id}/activate`
|
||||
) {
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
...PROBE_PROFILE,
|
||||
is_active: true,
|
||||
embedding_model: "synthetic-embedding",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { status: 404, data: {} };
|
||||
});
|
||||
const result = await capturedRun(
|
||||
["models", "add", "--config-file", configFile, "--activate", "--json"],
|
||||
{ watchWait: async () => undefined },
|
||||
);
|
||||
expect(result.code).toBe(0);
|
||||
const lines = result.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(lines[0].probe).toMatchObject({ state: "completed" });
|
||||
expect(lines.at(-1)).toMatchObject({ state: "committed" });
|
||||
expect(transport.post.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/api/v1/model-profiles",
|
||||
`/api/v1/model-profiles/${PROBE_PROFILE.profile_id}/probe`,
|
||||
"/api/v1/embedding-switches/plan",
|
||||
`/api/v1/model-profiles/${PROBE_PROFILE.profile_id}/activate`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not activate a profile whose probe fails", async () => {
|
||||
const configFile = await modelConfigFile();
|
||||
let probeId: string | undefined;
|
||||
transport.get.mockImplementation(async (url: string) => {
|
||||
if (url === "/api/v1/model-profiles") {
|
||||
return { status: 200, data: [PROBE_PROFILE] };
|
||||
}
|
||||
const match = /^\/api\/v1\/model-probe-operations\/(.+)$/.exec(url);
|
||||
if (match) {
|
||||
return {
|
||||
status: 200,
|
||||
data:
|
||||
probeId === undefined
|
||||
? probeNotFound(match[1])
|
||||
: probeReceipt(match[1]),
|
||||
};
|
||||
}
|
||||
return { status: 404, data: {} };
|
||||
});
|
||||
transport.post.mockImplementation(async (url: string, body: unknown) => {
|
||||
if (url === "/api/v1/model-profiles") {
|
||||
return { status: 200, data: PROBE_PROFILE };
|
||||
}
|
||||
if (url.endsWith("/probe")) {
|
||||
probeId = (body as { operation_id: string }).operation_id;
|
||||
return { status: 200, data: probeReceipt(probeId) };
|
||||
}
|
||||
return { status: 404, data: {} };
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"models",
|
||||
"add",
|
||||
@@ -527,14 +610,12 @@ describe("models add", () => {
|
||||
"--activate",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout).activation).toEqual({
|
||||
state: "committed",
|
||||
});
|
||||
expect(transport.post.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/api/v1/model-profiles",
|
||||
"/api/v1/model-profiles/profile-1/activate",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(
|
||||
transport.post.mock.calls.some(([url]) =>
|
||||
String(url).endsWith("/activate"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers an unknown create through its operation receipt", async () => {
|
||||
@@ -752,6 +833,54 @@ function probePending(operationId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function samePlan() {
|
||||
const space = {
|
||||
space_id: null,
|
||||
model: "synthetic-embedding",
|
||||
dims: 8,
|
||||
collection_base: "",
|
||||
};
|
||||
return {
|
||||
needed: false,
|
||||
reason: "same_embedding",
|
||||
plan_token: "token-1",
|
||||
source: space,
|
||||
target: space,
|
||||
target_profile_id: PROBE_PROFILE.profile_id,
|
||||
target_revision: 3,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
blockers: [],
|
||||
};
|
||||
}
|
||||
|
||||
function probeReceiptPassing(operationId: string) {
|
||||
const receipt = probeReceipt(operationId);
|
||||
return {
|
||||
...receipt,
|
||||
result: {
|
||||
...receipt.result,
|
||||
stages: [
|
||||
{
|
||||
stage: "embedding",
|
||||
ok: true,
|
||||
code: "ok",
|
||||
latency_ms: 1,
|
||||
embedding_dimensions: 8,
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
structured_output: true,
|
||||
embedding_available: true,
|
||||
},
|
||||
embedding_width: 8,
|
||||
embedding_switch_required: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function probeNotFound(operationId: string) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
@@ -819,7 +948,10 @@ function importJob() {
|
||||
};
|
||||
}
|
||||
|
||||
async function capturedRun(argv: string[]) {
|
||||
async function capturedRun(
|
||||
argv: string[],
|
||||
controls: Parameters<typeof run>[2] = {},
|
||||
) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const originalOut = process.stdout.write;
|
||||
@@ -833,7 +965,7 @@ async function capturedRun(argv: string[]) {
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
try {
|
||||
const code = await run(argv, {});
|
||||
const code = await run(argv, {}, controls);
|
||||
return { code, stdout, stderr };
|
||||
} finally {
|
||||
process.stdout.write = originalOut;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { parseFeaturesCommand } from "./memory-features-flow.js";
|
||||
import { composeStatusReport, renderStatusText } from "./status-report.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: vi.fn(() => transport),
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const DEFAULTS: Record<string, boolean> = {
|
||||
graph_rerank: false,
|
||||
same_series_timeline: false,
|
||||
episode_recall: false,
|
||||
session_episodes: false,
|
||||
episode_summary: false,
|
||||
intention: true,
|
||||
few_shot: false,
|
||||
};
|
||||
|
||||
function featuresState(
|
||||
configured: Record<string, boolean> = {},
|
||||
mode = "pro",
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
const nonDefault: Record<string, boolean> = {};
|
||||
const features = Object.entries(DEFAULTS).map(([name, fallback]) => {
|
||||
const enabled = configured[name] ?? fallback;
|
||||
if (enabled !== fallback) nonDefault[name] = enabled;
|
||||
const needsUltra = name === "graph_rerank";
|
||||
const reason = !enabled
|
||||
? "disabled"
|
||||
: needsUltra && mode !== "ultra"
|
||||
? "requires_ultra"
|
||||
: null;
|
||||
return {
|
||||
name,
|
||||
enabled,
|
||||
configured: name in configured ? configured[name] : null,
|
||||
default: fallback,
|
||||
active: reason === null,
|
||||
reason,
|
||||
side: "read",
|
||||
requires_mode: needsUltra ? "ultra" : "pro",
|
||||
requires_feature: null,
|
||||
cost: "none",
|
||||
sdk_field: `recall.${name}`,
|
||||
env: `MEMORY_${name.toUpperCase()}`,
|
||||
description: name,
|
||||
};
|
||||
});
|
||||
return { mode, features, non_default: nonDefault, ...extra };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue({
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
});
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/settings/memory-features"
|
||||
? { status: 200, data: featuresState() }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
});
|
||||
|
||||
describe("memhub settings features", () => {
|
||||
it("lists every feature with its value and whether it acts", async () => {
|
||||
const result = await capturedRun(["settings", "features", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.state).toBe("ok");
|
||||
expect(json.features.features).toHaveLength(7);
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
const text = (await capturedRun(["settings", "features"])).stdout;
|
||||
expect(text).toContain("graph_rerank:关(默认)");
|
||||
expect(text).toContain("intention:开(默认)");
|
||||
expect(text).toContain("先开启 session_episodes");
|
||||
});
|
||||
|
||||
it("prints the plan and changes nothing without --yes", async () => {
|
||||
const result = await capturedRun([
|
||||
"settings",
|
||||
"features",
|
||||
"set",
|
||||
"graph_rerank=true",
|
||||
"episode_recall=true",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toContain(
|
||||
"memhub settings features set graph_rerank=true episode_recall=true --yes",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets features with --yes and reports the ones inactive in this mode", async () => {
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: featuresState({ graph_rerank: true, episode_recall: true }, "pro", {
|
||||
changed: ["graph_rerank", "episode_recall"],
|
||||
}),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"settings",
|
||||
"features",
|
||||
"set",
|
||||
"graph_rerank=true",
|
||||
"episode_recall=true",
|
||||
"--yes",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).toHaveBeenCalledWith(
|
||||
"/api/v1/settings/memory-features",
|
||||
{ graph_rerank: true, episode_recall: true },
|
||||
undefined,
|
||||
);
|
||||
expect(result.stdout).toContain("已更新:graph_rerank、episode_recall");
|
||||
expect(result.stdout).toContain(
|
||||
"graph_rerank:开(已设置,当前模式下不生效(需要 ultra))",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when the values are already set", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: featuresState({ few_shot: true }),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"settings",
|
||||
"features",
|
||||
"set",
|
||||
"few_shot=true",
|
||||
"--yes",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("没有需要改动的设置");
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets the named features, or every set one, to the default", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: featuresState({ few_shot: true, intention: false }),
|
||||
});
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: featuresState({}, "pro", { changed: ["intention", "few_shot"] }),
|
||||
});
|
||||
expect(
|
||||
(await capturedRun(["settings", "features", "reset", "few_shot"])).code,
|
||||
).toBe(0);
|
||||
expect(transport.put).toHaveBeenLastCalledWith(
|
||||
"/api/v1/settings/memory-features",
|
||||
{ few_shot: null },
|
||||
undefined,
|
||||
);
|
||||
const all = await capturedRun(["settings", "features", "reset", "--json"]);
|
||||
expect(JSON.parse(all.stdout).state).toBe("committed");
|
||||
expect(transport.put).toHaveBeenLastCalledWith(
|
||||
"/api/v1/settings/memory-features",
|
||||
{ intention: null, few_shot: null },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses unknown names and malformed values before the Runtime", async () => {
|
||||
for (const bad of [
|
||||
["settings", "features", "set"],
|
||||
["settings", "features", "set", "local_rerank=true"],
|
||||
["settings", "features", "set", "graph_rerank=yes"],
|
||||
["settings", "features", "set", "graph_rerank"],
|
||||
["settings", "features", "set", "few_shot=true", "few_shot=false"],
|
||||
["settings", "features", "reset", "nope"],
|
||||
["settings", "features", "toggle"],
|
||||
]) {
|
||||
const result = await capturedRun(bad);
|
||||
expect(result.code, bad.join(" ")).toBe(2);
|
||||
}
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a 422 from the Runtime as rejected", async () => {
|
||||
transport.put.mockResolvedValue({
|
||||
status: 422,
|
||||
data: { detail: "invalid_request" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"settings",
|
||||
"features",
|
||||
"set",
|
||||
"few_shot=true",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout).state).toBe("rejected");
|
||||
});
|
||||
|
||||
it("parses the forms the help shows", () => {
|
||||
expect(parseFeaturesCommand([])).toEqual({ kind: "show" });
|
||||
expect(parseFeaturesCommand(["set", "intention=false", "--yes"])).toEqual({
|
||||
kind: "set",
|
||||
values: { intention: false },
|
||||
confirmed: true,
|
||||
});
|
||||
expect(parseFeaturesCommand(["reset"])).toEqual({
|
||||
kind: "reset",
|
||||
names: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("status pipeline features", () => {
|
||||
it("shows the features that differ from the defaults", async () => {
|
||||
const processing = {
|
||||
pending: 0,
|
||||
fresh_pending: 0,
|
||||
backlog_pending: 0,
|
||||
outside_window_pending: 0,
|
||||
running: 0,
|
||||
waiting_for_model: 0,
|
||||
completed: 3,
|
||||
failed: 0,
|
||||
blocked_stages: [],
|
||||
pipeline: {
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
forced_by_env: false,
|
||||
last_evolve_at: null,
|
||||
graph_build: { state: "idle", users: {} },
|
||||
evolve: { mode: "pro", schedule: null, running: [], users: {} },
|
||||
features: { episode_recall: true, intention: false },
|
||||
},
|
||||
};
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime: { state: "running", code: "ok", host: "127.0.0.1", port: 1 },
|
||||
background: null,
|
||||
reader: (async (path: string) =>
|
||||
path === "/api/v1/memory-processing"
|
||||
? { state: "ok", data: processing }
|
||||
: { state: "unavailable", code: "x" }) as never,
|
||||
});
|
||||
expect(report.pipeline?.features).toEqual({
|
||||
episode_recall: true,
|
||||
intention: false,
|
||||
});
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain("SDK 功能:episode_recall=开、intention=关");
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(arguments_: string[]) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return {
|
||||
code: await run(arguments_, { ONE_MEMORY_DATA_DIR: "/synthetic/data" }),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* `memhub settings features [set <name>=<true|false> ... | reset [<name> ...]]`.
|
||||
*
|
||||
* The SDK's optional retrieval and extraction features (graph rerank, episode
|
||||
* recall, the episode write side, intention, few-shot). One global setting in
|
||||
* the Runtime; an unset feature keeps the SDK default, so an install nobody
|
||||
* touched behaves as before. `set` prints its plan first and needs `--yes`,
|
||||
* like `settings mode`, because some features spend model calls; `reset`
|
||||
* only returns features to their defaults and applies at once.
|
||||
*/
|
||||
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CorePutOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export const MEMORY_FEATURES = [
|
||||
"graph_rerank",
|
||||
"same_series_timeline",
|
||||
"episode_recall",
|
||||
"session_episodes",
|
||||
"episode_summary",
|
||||
"intention",
|
||||
"few_shot",
|
||||
] as const;
|
||||
export type MemoryFeature = (typeof MEMORY_FEATURES)[number];
|
||||
|
||||
const FEATURE_LABEL: Record<MemoryFeature, string> = {
|
||||
graph_rerank: "图谱重排(沿知识图谱的边传播支持度;仅 ultra)",
|
||||
same_series_timeline:
|
||||
"同系列时间线(同一系列的记忆按时间放在一起;需图谱重排)",
|
||||
episode_recall:
|
||||
"片段召回(补入同一片段的记忆并多通道打分;pro / ultra,需片段写入)",
|
||||
session_episodes: "片段写入(同一会话提取的记忆归入同一片段)",
|
||||
episode_summary: "片段摘要(片段结束时总结一次;每个片段一次模型调用)",
|
||||
intention: "意图提取(随事实一起提取用户的计划与意图)",
|
||||
few_shot: "提取示例(在提取提示里附上示例;不加调用,输入更长)",
|
||||
};
|
||||
|
||||
const REASON_TEXT: Record<string, string> = {
|
||||
disabled: "未开启",
|
||||
requires_pro: "lite 模式下不生效(需要 pro 或 ultra)",
|
||||
requires_ultra: "当前模式下不生效(需要 ultra)",
|
||||
requires_graph_rerank: "需要先开启并生效 graph_rerank",
|
||||
};
|
||||
|
||||
export type FeaturesCommand =
|
||||
| { kind: "show" }
|
||||
| {
|
||||
kind: "set";
|
||||
values: Partial<Record<MemoryFeature, boolean>>;
|
||||
confirmed: boolean;
|
||||
}
|
||||
| { kind: "reset"; names: MemoryFeature[] };
|
||||
|
||||
function isFeature(value: string): value is MemoryFeature {
|
||||
return (MEMORY_FEATURES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Arguments after `settings features`; null = usage. */
|
||||
export function parseFeaturesCommand(
|
||||
values: readonly string[],
|
||||
): FeaturesCommand | null {
|
||||
const rest = values.filter((value) => value !== "--json");
|
||||
if (rest.length === 0) return { kind: "show" };
|
||||
const [verb, ...tail] = rest;
|
||||
const yes = tail.filter((value) => value === "--yes").length;
|
||||
if (yes > 1) return null;
|
||||
const positional = tail.filter((value) => value !== "--yes");
|
||||
if (verb === "set") {
|
||||
if (positional.length === 0) return null;
|
||||
const parsed: Partial<Record<MemoryFeature, boolean>> = {};
|
||||
for (const item of positional) {
|
||||
const match = /^([a-z_]+)=(true|false)$/.exec(item);
|
||||
if (!match || !isFeature(match[1]) || match[1] in parsed) return null;
|
||||
parsed[match[1]] = match[2] === "true";
|
||||
}
|
||||
return { kind: "set", values: parsed, confirmed: yes === 1 };
|
||||
}
|
||||
if (verb === "reset") {
|
||||
if (new Set(positional).size !== positional.length) return null;
|
||||
if (!positional.every(isFeature)) return null;
|
||||
return { kind: "reset", names: positional as MemoryFeature[] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FeatureState {
|
||||
name: MemoryFeature;
|
||||
enabled: boolean;
|
||||
configured: boolean | null;
|
||||
default: boolean;
|
||||
active: boolean;
|
||||
reason: string | null;
|
||||
cost: string;
|
||||
requires_mode: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface FeaturesState {
|
||||
mode: string;
|
||||
features: FeatureState[];
|
||||
non_default: Partial<Record<MemoryFeature, boolean>>;
|
||||
changed?: MemoryFeature[];
|
||||
}
|
||||
|
||||
function featuresState(value: unknown): FeaturesState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof item.mode !== "string" ||
|
||||
!Array.isArray(item.features) ||
|
||||
!item.non_default ||
|
||||
typeof item.non_default !== "object"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
for (const feature of item.features) {
|
||||
if (
|
||||
!feature ||
|
||||
typeof feature !== "object" ||
|
||||
typeof (feature as FeatureState).name !== "string" ||
|
||||
typeof (feature as FeatureState).enabled !== "boolean" ||
|
||||
typeof (feature as FeatureState).active !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return item as unknown as FeaturesState;
|
||||
}
|
||||
|
||||
export type FeaturesDocument = {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "settings features";
|
||||
} & (
|
||||
| { state: "ok"; features: FeaturesState }
|
||||
| {
|
||||
state: "preview";
|
||||
features: FeaturesState;
|
||||
target: Partial<Record<MemoryFeature, boolean>>;
|
||||
applied: false;
|
||||
next_step: string;
|
||||
}
|
||||
| { state: "unchanged"; features: FeaturesState }
|
||||
| { state: "committed"; features: FeaturesState; changed: MemoryFeature[] }
|
||||
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
||||
);
|
||||
|
||||
const BASE = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "settings features" as const,
|
||||
};
|
||||
|
||||
const PATH = "/api/v1/settings/memory-features";
|
||||
|
||||
export async function runFeaturesCommand(
|
||||
command: FeaturesCommand,
|
||||
reader: CoreReader,
|
||||
operations: CorePutOperations,
|
||||
): Promise<FeaturesDocument> {
|
||||
const read = await reader<unknown>(PATH);
|
||||
if (read.state !== "ok") {
|
||||
return { ...BASE, state: "unavailable", code: read.code };
|
||||
}
|
||||
const current = featuresState(read.data);
|
||||
if (current === null) {
|
||||
return { ...BASE, state: "unavailable", code: "response_invalid" };
|
||||
}
|
||||
if (command.kind === "show") {
|
||||
return { ...BASE, state: "ok", features: current };
|
||||
}
|
||||
let body: Record<string, unknown>;
|
||||
if (command.kind === "set") {
|
||||
const configured = new Map(
|
||||
current.features.map((feature) => [feature.name, feature.configured]),
|
||||
);
|
||||
const differs = Object.entries(command.values).some(
|
||||
([name, value]) => configured.get(name as MemoryFeature) !== value,
|
||||
);
|
||||
if (!differs) return { ...BASE, state: "unchanged", features: current };
|
||||
if (!command.confirmed) {
|
||||
const pairs = Object.entries(command.values)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join(" ");
|
||||
return {
|
||||
...BASE,
|
||||
state: "preview",
|
||||
features: current,
|
||||
target: command.values,
|
||||
applied: false,
|
||||
next_step: `${PRODUCT} settings features set ${pairs} --yes`,
|
||||
};
|
||||
}
|
||||
body = { ...command.values };
|
||||
} else {
|
||||
const names =
|
||||
command.names.length > 0
|
||||
? command.names
|
||||
: current.features
|
||||
.filter((feature) => feature.configured !== null)
|
||||
.map((feature) => feature.name);
|
||||
if (
|
||||
names.every(
|
||||
(name) =>
|
||||
current.features.find((feature) => feature.name === name)
|
||||
?.configured === null,
|
||||
)
|
||||
) {
|
||||
return { ...BASE, state: "unchanged", features: current };
|
||||
}
|
||||
body = Object.fromEntries(names.map((name) => [name, null]));
|
||||
}
|
||||
const result = await operations.put<unknown>(PATH, body);
|
||||
if (result.state !== "committed") {
|
||||
return { ...BASE, state: result.state, code: result.code };
|
||||
}
|
||||
const changed = featuresState(result.data);
|
||||
if (changed === null) {
|
||||
return { ...BASE, state: "unknown", code: "response_invalid" };
|
||||
}
|
||||
return {
|
||||
...BASE,
|
||||
state: "committed",
|
||||
features: changed,
|
||||
changed: changed.changed ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function featuresExitCode(document: FeaturesDocument): number {
|
||||
return ["ok", "preview", "unchanged", "committed"].includes(document.state)
|
||||
? 0
|
||||
: 1;
|
||||
}
|
||||
|
||||
function featureLine(feature: FeatureState): string {
|
||||
const label = FEATURE_LABEL[feature.name] ?? feature.name;
|
||||
const value = feature.enabled ? "开" : "关";
|
||||
const origin = feature.configured === null ? "默认" : "已设置";
|
||||
const activity =
|
||||
feature.enabled && !feature.active && feature.reason
|
||||
? `,${REASON_TEXT[feature.reason] ?? feature.reason}`
|
||||
: "";
|
||||
return `${feature.name}:${value}(${origin}${activity})— ${label}`;
|
||||
}
|
||||
|
||||
export function renderFeaturesText(document: FeaturesDocument): string {
|
||||
if ("code" in document) {
|
||||
return `SDK 功能未能${document.state === "unavailable" ? "读取" : "修改"}:${document.code}\n`;
|
||||
}
|
||||
const state = document.features;
|
||||
const lines = [`当前模式:${state.mode}`];
|
||||
for (const feature of state.features) lines.push(featureLine(feature));
|
||||
lines.push(
|
||||
"片段召回(episode_recall)只对带片段的记忆有用:先开启 session_episodes,之后提取的记忆才会带片段。",
|
||||
);
|
||||
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
|
||||
if (document.state === "preview") {
|
||||
lines.push("将设置:");
|
||||
for (const [name, value] of Object.entries(document.target)) {
|
||||
lines.push(`- ${name} = ${value ? "开" : "关"}`);
|
||||
}
|
||||
lines.push(`未执行任何改动。确认后运行:${document.next_step}`);
|
||||
}
|
||||
if (document.state === "committed") {
|
||||
lines.push(
|
||||
document.changed.length > 0
|
||||
? `已更新:${document.changed.join("、")}。正在进行的写入按原设置完成,之后的写入和检索使用新设置。`
|
||||
: "已保存,生效的值没有变化。",
|
||||
);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -372,6 +372,9 @@ export function renderMemoryAddText(document: MemoryAddDocument): string {
|
||||
export function renderMemoryPageText(document: MemoryPageDocument): string {
|
||||
const page = document.page;
|
||||
if (page.state !== "ok") {
|
||||
if (page.state === "unavailable" && page.code === "embedding_unavailable") {
|
||||
return "向量服务不可用,无法检索;请检查向量模型配置。\n";
|
||||
}
|
||||
if (page.state === "unavailable") {
|
||||
return "后台正忙,记忆列表这次没查完,不代表没有记忆,请稍后再试\n";
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ const RESULT_KEYS = new Set([
|
||||
"stages",
|
||||
"capabilities",
|
||||
]);
|
||||
const OPTIONAL_RESULT_KEYS = [
|
||||
"embedding_width",
|
||||
"embedding_switch_required",
|
||||
] as const;
|
||||
const STAGE_KEYS = new Set([
|
||||
"stage",
|
||||
"ok",
|
||||
@@ -272,9 +276,34 @@ function validateResult(
|
||||
inputRevision: number,
|
||||
): Record<string, unknown> | null {
|
||||
const result = record(value);
|
||||
// A Core with the embedding registry adds what the embedding stage measured
|
||||
// and whether using it needs an embedding switch; older Cores send neither.
|
||||
const optional = Object.fromEntries(
|
||||
OPTIONAL_RESULT_KEYS.filter((key) => key in (result ?? {})).map((key) => [
|
||||
key,
|
||||
result?.[key],
|
||||
]),
|
||||
);
|
||||
const required = result
|
||||
? Object.fromEntries(
|
||||
Object.entries(result).filter(
|
||||
([key]) => !(OPTIONAL_RESULT_KEYS as readonly string[]).includes(key),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
if (
|
||||
!result ||
|
||||
!exactKeys(result, RESULT_KEYS) ||
|
||||
!required ||
|
||||
!exactKeys(required, RESULT_KEYS) ||
|
||||
!(
|
||||
optional.embedding_width === undefined ||
|
||||
optional.embedding_width === null ||
|
||||
positiveInteger(optional.embedding_width)
|
||||
) ||
|
||||
!(
|
||||
optional.embedding_switch_required === undefined ||
|
||||
typeof optional.embedding_switch_required === "boolean"
|
||||
) ||
|
||||
result.profile_id !== profileId ||
|
||||
result.input_profile_revision !== inputRevision ||
|
||||
!positiveInteger(result.profile_revision) ||
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function editModelProfile(
|
||||
return modelProfileResult(profileId, result);
|
||||
}
|
||||
|
||||
function modelProfileResult(
|
||||
export function modelProfileResult(
|
||||
operationId: string,
|
||||
result:
|
||||
| Awaited<ReturnType<CoreOperations["post"]>>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
validateProcessing,
|
||||
validateSources,
|
||||
} from "./core-responses.js";
|
||||
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
const MIGRATION_ISSUES_URL =
|
||||
@@ -83,6 +84,8 @@ export interface PipelineSummary {
|
||||
}
|
||||
>;
|
||||
graph_build: string | null;
|
||||
/** SDK features (`memory_features`) whose value differs from the default. */
|
||||
features: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const MODES = new Set(["lite", "pro", "ultra"]);
|
||||
@@ -127,6 +130,14 @@ export function describePipeline(value: unknown): PipelineSummary | null {
|
||||
};
|
||||
}
|
||||
}
|
||||
const features: Record<string, boolean> = {};
|
||||
if (item.features && typeof item.features === "object") {
|
||||
for (const [name, value] of Object.entries(
|
||||
item.features as Record<string, unknown>,
|
||||
)) {
|
||||
if (typeof value === "boolean") features[name] = value;
|
||||
}
|
||||
}
|
||||
const graph =
|
||||
item.graph_build && typeof item.graph_build === "object"
|
||||
? (item.graph_build as Record<string, unknown>)
|
||||
@@ -146,6 +157,7 @@ export function describePipeline(value: unknown): PipelineSummary | null {
|
||||
evolve_running: Array.isArray(evolve.running) ? evolve.running.length : 0,
|
||||
evolve_users: users,
|
||||
graph_build: typeof graph.state === "string" ? graph.state : null,
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,6 +307,10 @@ export function renderStatusText(
|
||||
);
|
||||
}
|
||||
if (report.pipeline) lines.push(`记忆模式:${pipelineLine(report.pipeline)}`);
|
||||
const embeddingSwitch = describeEmbeddingSwitchSummary(
|
||||
report.sections.processing?.embedding_switch,
|
||||
);
|
||||
if (embeddingSwitch !== null) lines.push(`向量模型:${embeddingSwitch}`);
|
||||
if (report.summary.state === "partial") {
|
||||
const notes = [
|
||||
report.summary.degraded.length > 0
|
||||
@@ -329,6 +345,14 @@ export function pipelineLine(pipeline: PipelineSummary): string {
|
||||
if (pipeline.evolve_running > 0)
|
||||
parts.push(`,进行中 ${pipeline.evolve_running}`);
|
||||
}
|
||||
const changed = Object.entries(pipeline.features);
|
||||
if (changed.length > 0) {
|
||||
parts.push(
|
||||
`;SDK 功能:${changed
|
||||
.map(([name, value]) => `${name}=${value ? "开" : "关"}`)
|
||||
.join("、")}`,
|
||||
);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
@@ -346,7 +370,10 @@ function sectionLine(section: Section | undefined): string {
|
||||
if (section.state === "not_checked") return "未检查";
|
||||
if (section.state !== "ok") return "暂时不可用,请稍后重试";
|
||||
return Object.entries(section)
|
||||
.filter(([key]) => key !== "state" && key !== "code")
|
||||
.filter(
|
||||
([key]) =>
|
||||
key !== "state" && key !== "code" && key !== "embedding_switch",
|
||||
)
|
||||
.map(([key, value]) => `${fieldLabel(key)} ${formatValue(value)}`)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -462,9 +489,30 @@ function describeProcessing(
|
||||
reason: block.reason,
|
||||
count: block.count,
|
||||
})),
|
||||
...embeddingSwitchField(summary),
|
||||
};
|
||||
}
|
||||
|
||||
/** The running switch or merge, projected; absent when none runs. */
|
||||
function embeddingSwitchField(
|
||||
summary: MemoryProcessingSummary,
|
||||
): Record<string, unknown> {
|
||||
const value = (summary as unknown as Record<string, unknown>)
|
||||
.embedding_switch;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const item = value as Record<string, unknown>;
|
||||
const kind = item.kind === "merge" ? "merge" : "switch";
|
||||
const state =
|
||||
typeof item.state === "string" && /^[a-z_]{1,32}$/.test(item.state)
|
||||
? item.state
|
||||
: "unknown";
|
||||
const percent =
|
||||
typeof item.percent === "number" && Number.isFinite(item.percent)
|
||||
? Math.min(100, Math.max(0, item.percent))
|
||||
: 0;
|
||||
return { embedding_switch: { kind, state, percent } };
|
||||
}
|
||||
|
||||
/** Projection, not passthrough: a Core that leaks a key must not make us leak. */
|
||||
function describeModel(profiles: ModelProfile[]): Record<string, unknown> {
|
||||
const active = profiles.find((profile) => profile.is_active);
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.9",
|
||||
"hy-memory==2.0.10",
|
||||
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
||||
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
||||
"chromadb==1.5.9",
|
||||
|
||||
@@ -71,6 +71,9 @@ from one_memory.diagnostic_log import (
|
||||
reset_trace_id,
|
||||
resource_snapshot,
|
||||
)
|
||||
from one_memory.embedding_health import EMBEDDING_UNAVAILABLE, EmbeddingUnavailableError
|
||||
from one_memory.embedding_spaces import EmbeddingSpaceStore
|
||||
from one_memory.embedding_switch import EmbeddingSwitchJob, EmbeddingSwitchService
|
||||
from one_memory.exception_reporting import (
|
||||
install_asyncio_exception_handler,
|
||||
install_threading_excepthook,
|
||||
@@ -104,6 +107,7 @@ from one_memory.hy_sdk_runtime import (
|
||||
close_all_shared_runtimes,
|
||||
hold_stores_offline,
|
||||
release_stores,
|
||||
sdk_feature_defaults,
|
||||
sdk_relevance_floor,
|
||||
)
|
||||
from one_memory.hy_sdk_types import (
|
||||
@@ -192,6 +196,18 @@ from one_memory.memory_evolution import (
|
||||
MemoryGraphBackfill,
|
||||
UserEvolveState,
|
||||
)
|
||||
from one_memory.memory_features import (
|
||||
FEATURES,
|
||||
FeatureCost,
|
||||
FeatureOverrides,
|
||||
FeatureReason,
|
||||
FeatureSide,
|
||||
MemoryFeatureName,
|
||||
MemoryFeatures,
|
||||
effective_features,
|
||||
feature_activity,
|
||||
non_default_features,
|
||||
)
|
||||
from one_memory.memory_governance import (
|
||||
MemoryGovernanceStore,
|
||||
)
|
||||
@@ -224,6 +240,7 @@ from one_memory.model_profiles import (
|
||||
ModelProfilePatch,
|
||||
ModelProfileStore,
|
||||
ModelProfileUnavailableError,
|
||||
ProviderKind,
|
||||
)
|
||||
from one_memory.model_request_audit import (
|
||||
AuditedModelTransport,
|
||||
@@ -317,6 +334,10 @@ class HealthResponse(BaseModel):
|
||||
# failed steps are named so a stuck recovery is visible without a log.
|
||||
recovery: Literal["pending", "running", "done", "failed"] = "done"
|
||||
recovery_failed_steps: tuple[str, ...] = ()
|
||||
# An embedding switch or a startup merge of stray spaces, while one runs
|
||||
# (`{kind, state, percent, eta_s}`): the loading screen and the status
|
||||
# line read it. Reads are served throughout; model writes are paused.
|
||||
embedding_switch: dict[str, object] | None = None
|
||||
|
||||
|
||||
class BackupRequest(BaseModel):
|
||||
@@ -653,6 +674,9 @@ class MemoryProcessingSummary(BaseModel):
|
||||
#: R10: the SDK mode and System 2 state. Optional so older readers and
|
||||
#: test doubles stay valid; Core always sends it.
|
||||
pipeline: "PipelineStatus | None" = None
|
||||
#: The running embedding switch or merge, `{job_id, kind, state, percent,
|
||||
#: eta_s}`; None when none runs. Model writes wait while it is set.
|
||||
embedding_switch: dict[str, object] | None = None
|
||||
|
||||
|
||||
class MemoryModeState(BaseModel):
|
||||
@@ -732,6 +756,52 @@ class PipelineStatus(BaseModel):
|
||||
#: Newest finished run across users, for a one-line status.
|
||||
last_evolve_at: datetime | None = None
|
||||
graph_build: GraphBuildState
|
||||
#: The SDK features (`memory_features`) whose value differs from the SDK
|
||||
#: default; empty while every feature is at its default.
|
||||
features: dict[MemoryFeatureName, bool] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MemoryFeatureState(BaseModel):
|
||||
"""One feature in `GET /api/v1/settings/memory-features`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: MemoryFeatureName
|
||||
#: What clients are built with: `configured`, else `default`.
|
||||
enabled: bool
|
||||
#: What a person set; null = not set, the SDK default applies.
|
||||
configured: bool | None
|
||||
#: The SDK default (its code default, or its own env var when set).
|
||||
default: bool
|
||||
#: Whether it acts in the current mode; `reason` says why not.
|
||||
active: bool
|
||||
reason: FeatureReason | None
|
||||
side: FeatureSide
|
||||
requires_mode: Literal["pro", "ultra"]
|
||||
requires_feature: MemoryFeatureName | None
|
||||
cost: FeatureCost
|
||||
#: The `MemoryConfig` field it sets, and the SDK's own env var for it.
|
||||
sdk_field: str
|
||||
env: str
|
||||
description: str
|
||||
|
||||
|
||||
class MemoryFeaturesState(BaseModel):
|
||||
"""`GET /api/v1/settings/memory-features`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: MemoryMode
|
||||
features: tuple[MemoryFeatureState, ...]
|
||||
#: Same as `pipeline.features`: the values that differ from the defaults.
|
||||
non_default: dict[MemoryFeatureName, bool]
|
||||
|
||||
|
||||
class MemoryFeaturesChange(MemoryFeaturesState):
|
||||
"""`PUT /api/v1/settings/memory-features`: the new state and what changed."""
|
||||
|
||||
#: Features whose value changed; empty = nothing rebuilt.
|
||||
changed: tuple[MemoryFeatureName, ...] = ()
|
||||
|
||||
|
||||
def memory_mode_effects(
|
||||
@@ -985,6 +1055,16 @@ def create_app(
|
||||
# state, so initialize it before authority selection.
|
||||
app.state.secret_store = secret_store or KeyringSecretStore()
|
||||
app.state.model_profile_store = ModelProfileStore(database_path)
|
||||
# The live embedding space, pinned before any SDK client opens.
|
||||
# A home this build has not seen registers the space it already
|
||||
# uses, under the name it already has (`EmbeddingSpaceStore.bootstrap`).
|
||||
app.state.embedding_spaces = EmbeddingSpaceStore(database_path)
|
||||
active_identity = app.state.model_profile_store.active_identity()
|
||||
app.state.embedding_spaces.bootstrap(
|
||||
app.state.model_profile_store.snapshot(*active_identity)
|
||||
if active_identity is not None
|
||||
else None
|
||||
)
|
||||
app.state.sdk_data_plane_migrations = SdkDataPlaneMigrationStore(database_path)
|
||||
app.state.memory_authority = app.state.sdk_data_plane_migrations.authority()
|
||||
_record_startup_authority(
|
||||
@@ -1220,6 +1300,9 @@ def create_app(
|
||||
),
|
||||
sedimentation_enabled=lambda: _config_store(app).sedimentation_enabled(),
|
||||
memory_mode=lambda: _memory_mode(app),
|
||||
memory_features=lambda: _memory_features(app),
|
||||
spaces=app.state.embedding_spaces,
|
||||
writes_open=lambda: _embedding_writes_open(app),
|
||||
)
|
||||
app.state.sdk_memory_runtime = SdkMemoryRuntime(
|
||||
coordinator=app.state.sdk_write_coordinator,
|
||||
@@ -1228,6 +1311,7 @@ def create_app(
|
||||
extraction_settings=app.state.extraction_settings_store,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
memory_mode=lambda: _memory_mode(app),
|
||||
writes_open=lambda: _embedding_writes_open(app),
|
||||
)
|
||||
app.state.evolve_state_store = EvolveStateStore(runtime_settings.data_dir)
|
||||
app.state.evolve_service = EvolveService(
|
||||
@@ -1282,6 +1366,10 @@ def create_app(
|
||||
async with app.state.sedimentation_lock, source_lock:
|
||||
if not _config_store(app).sedimentation_enabled():
|
||||
raise RuntimeError("model_sedimentation_disabled")
|
||||
if not _embedding_writes_open(app):
|
||||
# Re-checked at the last boundary: a switch that began
|
||||
# after the claim keeps this job queued, unsent.
|
||||
raise RuntimeError("embedding_switch_in_progress")
|
||||
if not _source_writes_on(app, source_id):
|
||||
raise RuntimeError("model_content_source_disabled")
|
||||
return await mark_invoked()
|
||||
@@ -1333,6 +1421,64 @@ def create_app(
|
||||
source_claim_enabled=lambda source_id: _sedimentation_claim_enabled(app, source_id),
|
||||
)
|
||||
app.state.memory_write_drain = memory_write_drain
|
||||
|
||||
async def embedding_writes_drained() -> bool:
|
||||
"""No model write runs: none past its claim, none holding a client."""
|
||||
await asyncio.to_thread(memory_write_queue.reclaim_expired)
|
||||
running, _ = await asyncio.to_thread(app.state.sdk_memory_runtime.active_depths)
|
||||
in_use = cast(SdkWriteCoordinator, app.state.sdk_write_coordinator).in_use()
|
||||
return running == 0 and in_use == 0
|
||||
|
||||
async def quiesce_sdk_clients() -> Callable[[], None]:
|
||||
"""Close every SDK client and hold the stores, for the graph re-embed."""
|
||||
deadline = time.monotonic() + 120
|
||||
while True:
|
||||
await _read_handle(app).close_idle()
|
||||
await app.state.sdk_write_coordinator.close_idle()
|
||||
if hold_stores_offline(runtime_settings.data_dir):
|
||||
return lambda: release_stores(runtime_settings.data_dir)
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("embedding_switch_clients_busy")
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
async def after_embedding_commit(job: EmbeddingSwitchJob) -> None:
|
||||
from one_memory.hy_graph_admin import forget_graph_widths
|
||||
|
||||
forget_graph_widths()
|
||||
_read_handle(app).health.forget()
|
||||
if job.kind == "switch" and job.target_profile_id is not None:
|
||||
profile = await asyncio.to_thread(
|
||||
_model_profile_store(app).require,
|
||||
job.target_profile_id,
|
||||
_secret_store(app),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
app.state.memory_task_store.rebind_unstarted_profile, profile
|
||||
)
|
||||
await asyncio.to_thread(memory_write_queue.readmit_model_profile_waiters)
|
||||
_resume_sedimentation(app)
|
||||
_start_backfill(app)
|
||||
|
||||
from one_memory.embedding_switch import default_reembed_backend
|
||||
|
||||
app.state.embedding_switch = EmbeddingSwitchService(
|
||||
database_path=database_path,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
profiles=app.state.model_profile_store,
|
||||
spaces=app.state.embedding_spaces,
|
||||
secrets=app.state.secret_store,
|
||||
backend=default_reembed_backend(runtime_settings.data_dir),
|
||||
drain_idle=embedding_writes_drained,
|
||||
on_commit=after_embedding_commit,
|
||||
quiesce_clients=quiesce_sdk_clients,
|
||||
# Only ultra keeps a graph; its width follows the space.
|
||||
graph_enabled=lambda: _memory_mode(app) == "ultra",
|
||||
worker_enabled=runtime_settings.embedding_switch_worker,
|
||||
drain_timeout_s=runtime_settings.embedding_switch_drain_timeout_seconds,
|
||||
)
|
||||
if app.state.memory_authority is not MemoryAuthority.SDK:
|
||||
# Only an SDK-authority home has SDK spaces to scan and merge.
|
||||
app.state.embedding_switch.release_scan_hold()
|
||||
# Started by `recover_after_ready`: a running drain claims jobs and
|
||||
# holds the single writer, which the rest of startup still needs.
|
||||
app.state.memory_write_drain_task = None
|
||||
@@ -1571,6 +1717,12 @@ def create_app(
|
||||
repair.coding_rows,
|
||||
)
|
||||
|
||||
async def resume_embedding_spaces() -> None:
|
||||
try:
|
||||
await app.state.embedding_switch.recover()
|
||||
finally:
|
||||
app.state.embedding_switch.start_retention()
|
||||
|
||||
async def start_write_drain() -> None:
|
||||
app.state.memory_write_drain_task = watched_task(
|
||||
asyncio.get_running_loop(),
|
||||
@@ -1659,6 +1811,13 @@ def create_app(
|
||||
# Before the drain: the repair restores metadata the
|
||||
# queued writes are read back against.
|
||||
await step("sdk_metadata_repair", repair_sdk_metadata)
|
||||
# Before the drain too: resume an unfinished embedding
|
||||
# switch (an App update may have stopped it), or scan for
|
||||
# stray spaces and start merging them. Until this decides,
|
||||
# the write gate holds every model write back; reads are
|
||||
# served from the live space throughout.
|
||||
if sdk:
|
||||
await step("embedding_spaces", resume_embedding_spaces)
|
||||
await step("write_drain", start_write_drain)
|
||||
if sdk:
|
||||
await step("evolve_schedule", start_evolve_schedule)
|
||||
@@ -1761,6 +1920,8 @@ def create_app(
|
||||
await app.state.evolve_scheduler.shutdown()
|
||||
await app.state.evolve_service.shutdown()
|
||||
await app.state.memory_graph_backfill.shutdown()
|
||||
# The job stays where it is; the next start resumes it.
|
||||
await app.state.embedding_switch.shutdown()
|
||||
await app.state.sdk_memory_runtime.shutdown()
|
||||
await app.state.sdk_write_coordinator.close()
|
||||
if app.state.sdk_client_handle is not None:
|
||||
@@ -1846,6 +2007,9 @@ def create_app(
|
||||
from one_memory.call_event_routes import call_event_router
|
||||
|
||||
app.include_router(call_event_router(protected))
|
||||
from one_memory.embedding_switch_routes import embedding_switch_router
|
||||
|
||||
app.include_router(embedding_switch_router(protected))
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -1939,6 +2103,12 @@ def create_app(
|
||||
},
|
||||
)
|
||||
|
||||
@app.exception_handler(EmbeddingUnavailableError)
|
||||
async def embedding_unavailable(_: Request, _error: EmbeddingUnavailableError) -> JSONResponse:
|
||||
# No lexical fallback: a search that cannot embed its query fails,
|
||||
# and says why, so the App can show it (top-right toast).
|
||||
return JSONResponse(status_code=503, content={"detail": EMBEDDING_UNAVAILABLE})
|
||||
|
||||
@app.exception_handler(sqlite3.OperationalError)
|
||||
async def sqlite_error(request: Request, error: sqlite3.OperationalError) -> JSONResponse:
|
||||
record_database_error("core.api", error, route_path=request.url.path)
|
||||
@@ -1984,6 +2154,7 @@ def create_app(
|
||||
),
|
||||
recovery=getattr(app.state, "recovery", "pending"),
|
||||
recovery_failed_steps=getattr(app.state, "recovery_failed_steps", ()),
|
||||
embedding_switch=_embedding_switch_summary(app),
|
||||
)
|
||||
|
||||
@app.get("/health", response_model=HealthResponse, include_in_schema=False)
|
||||
@@ -3262,6 +3433,7 @@ def create_app(
|
||||
profile_id: str,
|
||||
request: ModelProfilePatch,
|
||||
) -> ModelProfile:
|
||||
_refuse_embedding_edit_during_switch(app, profile_id, request)
|
||||
try:
|
||||
return _model_profile_store(app).update(
|
||||
profile_id,
|
||||
@@ -3282,25 +3454,62 @@ def create_app(
|
||||
|
||||
@app.post(
|
||||
"/api/v1/model-profiles/{profile_id}/activate",
|
||||
response_model=ModelProfile,
|
||||
response_model=None,
|
||||
dependencies=protected,
|
||||
)
|
||||
async def activate_model_profile(profile_id: str) -> ModelProfile:
|
||||
async def activate_model_profile(profile_id: str) -> Any:
|
||||
# Asynchronous for the sake of what follows the activation: scheduling
|
||||
# work needs the event loop this coroutine runs on, and a synchronous
|
||||
# path operation is handed to a worker thread that has none. Activating
|
||||
# with any evidence already imported — the ordinary order, since
|
||||
# importing comes first — used to fail here outright.
|
||||
#
|
||||
# Activation never changes the embedding space by itself. A profile
|
||||
# with the live space's model and width (a generation-only change, or
|
||||
# another endpoint for the same model) activates as before. Another
|
||||
# embedding on a home with no vector at all moves the space in the
|
||||
# same transaction. Anything else answers 409 `embedding_switch_required`
|
||||
# with the plan: `POST /api/v1/embedding-switches` carries it out.
|
||||
from one_memory.embedding_switch import EmbeddingSwitchError
|
||||
from one_memory.embedding_switch_routes import refusal
|
||||
|
||||
switch = _embedding_switch(app)
|
||||
try:
|
||||
profile = await asyncio.to_thread(
|
||||
_model_profile_store(app).activate,
|
||||
profile_id,
|
||||
_secret_store(app),
|
||||
)
|
||||
# The refusals activation always made come first, with their codes.
|
||||
await asyncio.to_thread(_store_activation_preflight, app, profile_id)
|
||||
plan = await switch.plan(profile_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="model_profile_not_found") from None
|
||||
except ModelProfileUnavailableError as error:
|
||||
raise HTTPException(status_code=409, detail=str(error)) from None
|
||||
except EmbeddingSwitchError as error:
|
||||
if error.code == "model_profile_not_found":
|
||||
raise HTTPException(status_code=404, detail="model_profile_not_found") from None
|
||||
return refusal(error.code, plan=error.plan)
|
||||
if "embedding_switch_running" in plan.blockers:
|
||||
return refusal("embedding_switch_running", plan=plan)
|
||||
try:
|
||||
if plan.needed:
|
||||
return refusal("embedding_switch_required", plan=plan)
|
||||
if plan.reason == "empty_home":
|
||||
await asyncio.to_thread(switch.activate_empty_home, profile_id)
|
||||
profile = await asyncio.to_thread(
|
||||
_model_profile_store(app).require, profile_id, _secret_store(app)
|
||||
)
|
||||
else:
|
||||
profile = await asyncio.to_thread(
|
||||
_model_profile_store(app).activate,
|
||||
profile_id,
|
||||
_secret_store(app),
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="model_profile_not_found") from None
|
||||
except ModelProfileUnavailableError as error:
|
||||
raise HTTPException(status_code=409, detail=str(error)) from None
|
||||
except ModelProfileConflictError:
|
||||
raise HTTPException(status_code=409, detail="model_profile_conflict") from None
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code)
|
||||
# D75: work queued for the previous model continues with this one.
|
||||
# Only tasks that never reached the model are re-bound.
|
||||
await asyncio.to_thread(app.state.memory_task_store.rebind_unstarted_profile, profile)
|
||||
@@ -3873,6 +4082,7 @@ def create_app(
|
||||
if request.task is not None and request.kind in {"chat", "fact"}:
|
||||
raise HTTPException(status_code=422, detail="memory_task_requires_experience")
|
||||
namespace = _namespace_kwargs(request.kind, request.variant)
|
||||
_refuse_write_during_embedding_switch(app)
|
||||
try:
|
||||
async with app.state.sdk_write_coordinator.use_client() as client:
|
||||
# `update` takes no user: read the memory first and refuse
|
||||
@@ -3928,6 +4138,7 @@ def create_app(
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> Response:
|
||||
_check_memory_namespace(kind, variant)
|
||||
_refuse_write_during_embedding_switch(app)
|
||||
try:
|
||||
async with app.state.sdk_write_coordinator.use_client() as client:
|
||||
# `delete` takes no user. Another user's memory is answered
|
||||
@@ -4236,6 +4447,7 @@ def create_app(
|
||||
in_flight_count=in_flight_count,
|
||||
metrics=ProcessingMetrics.model_validate(store.processing_metrics()),
|
||||
pipeline=_pipeline_status(app),
|
||||
embedding_switch=_embedding_switch_summary(app),
|
||||
)
|
||||
|
||||
@app.get(
|
||||
@@ -4296,6 +4508,58 @@ def create_app(
|
||||
effects=memory_mode_effects(previous_effective, effective, graph_backfill=backfill),
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings/memory-features",
|
||||
response_model=MemoryFeaturesState,
|
||||
dependencies=strict,
|
||||
)
|
||||
def get_memory_features() -> MemoryFeaturesState:
|
||||
"""The SDK retrieval and extraction features: value, default, active in this mode."""
|
||||
return _memory_features_state(app)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/settings/memory-features",
|
||||
response_model=MemoryFeaturesChange,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def put_memory_features(request: MemoryFeatures) -> MemoryFeaturesChange:
|
||||
"""Turn SDK features on or off; one global setting, not per user.
|
||||
|
||||
A key sets that feature, null resets it to the SDK default, an absent
|
||||
key is left as it is; an unknown key or a non-boolean is 422. A
|
||||
change rebuilds the SDK clients like a mode change: a write in flight
|
||||
finishes with the old features, the next job and search use the new
|
||||
ones. Nothing stored is migrated and no collection is renamed. See
|
||||
docs/contracts/memory-features-v1.md.
|
||||
"""
|
||||
store = _config_store(app)
|
||||
async with app.state.memory_mode_lock:
|
||||
defaults = await asyncio.to_thread(sdk_feature_defaults)
|
||||
previous = _memory_features(app)
|
||||
updated = store.memory_features().merged(request)
|
||||
if updated != store.memory_features():
|
||||
await asyncio.to_thread(store.set_memory_features, updated)
|
||||
current = _memory_features(app)
|
||||
before = effective_features(previous, defaults)
|
||||
after = effective_features(current, defaults)
|
||||
changed = tuple(spec.name for spec in FEATURES if before[spec.name] != after[spec.name])
|
||||
if current != previous:
|
||||
# Even a reset to the default value rebuilds: the client then
|
||||
# carries no override at all, byte for byte the default config.
|
||||
await app.state.sdk_write_coordinator.retire_stale_handles()
|
||||
# Reads rebuild on their next request (`ActiveProfileReadHandle`).
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="memory.features",
|
||||
event="memory_features.changed",
|
||||
outcome="succeeded",
|
||||
details={"changed": list(changed), "set": [name for name, _ in current]},
|
||||
)
|
||||
return MemoryFeaturesChange(
|
||||
**_memory_features_state(app, defaults).model_dump(),
|
||||
changed=changed,
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings/evolve-schedule",
|
||||
response_model=EvolveSchedule,
|
||||
@@ -4433,6 +4697,7 @@ def create_app(
|
||||
assumption that cost a live graph once was made in code, not by a user.
|
||||
"""
|
||||
supervisor = _sdk_memory_runtime(app)
|
||||
_refuse_write_during_embedding_switch(app)
|
||||
try:
|
||||
outcome = await supervisor.decide_graph_width(request.decision)
|
||||
except HySdkError as error:
|
||||
@@ -5648,13 +5913,15 @@ def _sdk_client_handle(app: FastAPI) -> SdkClientHandle:
|
||||
handle = cast(SdkClientHandle | None, app.state.sdk_client_handle)
|
||||
if handle is None:
|
||||
# Reads open the embedding space writes fill: the active profile's,
|
||||
# the built-in one only when none is active (#242). A profile or a
|
||||
# memory-mode change rebuilds the client.
|
||||
# the built-in one only when none is active (#242). A profile, a
|
||||
# memory-mode or a feature change rebuilds the client.
|
||||
handle = ActiveProfileReadHandle(
|
||||
profiles=_model_profile_store(app),
|
||||
secrets=_secret_store(app),
|
||||
data_dir=cast(Settings, app.state.settings).data_dir,
|
||||
memory_mode=lambda: _memory_mode(app),
|
||||
features=lambda: _memory_features(app),
|
||||
spaces=getattr(app.state, "embedding_spaces", None),
|
||||
)
|
||||
app.state.sdk_client_handle = handle
|
||||
return handle
|
||||
@@ -5691,6 +5958,47 @@ def _evolve_overview(app: FastAPI) -> EvolveOverview:
|
||||
)
|
||||
|
||||
|
||||
def _memory_features(app: FastAPI) -> FeatureOverrides:
|
||||
"""The SDK features a person set, as the key every SDK client is built with."""
|
||||
store = getattr(app.state, "config_store", None)
|
||||
return store.memory_features().overrides() if store is not None else ()
|
||||
|
||||
|
||||
def _memory_features_state(
|
||||
app: FastAPI, defaults: dict[str, bool] | None = None
|
||||
) -> MemoryFeaturesState:
|
||||
defaults = sdk_feature_defaults() if defaults is None else defaults
|
||||
overrides = _memory_features(app)
|
||||
configured = dict(overrides)
|
||||
effective = effective_features(overrides, defaults)
|
||||
mode = _memory_mode(app)
|
||||
features = []
|
||||
for spec in FEATURES:
|
||||
reason = feature_activity(spec.name, effective, mode)
|
||||
features.append(
|
||||
MemoryFeatureState(
|
||||
name=spec.name,
|
||||
enabled=effective[spec.name],
|
||||
configured=configured.get(spec.name),
|
||||
default=defaults[spec.name],
|
||||
active=reason is None,
|
||||
reason=reason,
|
||||
side=spec.side,
|
||||
requires_mode=spec.requires_mode,
|
||||
requires_feature=spec.requires_feature,
|
||||
cost=spec.cost,
|
||||
sdk_field=spec.sdk_field,
|
||||
env=spec.env,
|
||||
description=spec.description,
|
||||
)
|
||||
)
|
||||
return MemoryFeaturesState(
|
||||
mode=mode,
|
||||
features=tuple(features),
|
||||
non_default=cast(dict[MemoryFeatureName, bool], non_default_features(overrides, defaults)),
|
||||
)
|
||||
|
||||
|
||||
def _pipeline_status(app: FastAPI) -> PipelineStatus | None:
|
||||
"""R10's `pipeline`, or None before the evolve state exists (legacy tests)."""
|
||||
if getattr(app.state, "evolve_state_store", None) is None:
|
||||
@@ -5707,9 +6015,17 @@ def _pipeline_status(app: FastAPI) -> PipelineStatus | None:
|
||||
evolve=overview,
|
||||
last_evolve_at=max(finished, default=None),
|
||||
graph_build=state.graph_build,
|
||||
features=cast(
|
||||
dict[MemoryFeatureName, bool],
|
||||
non_default_features(_memory_features(app), sdk_feature_defaults()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _read_handle(app: FastAPI) -> ActiveProfileReadHandle:
|
||||
return cast(ActiveProfileReadHandle, _sdk_client_handle(app))
|
||||
|
||||
|
||||
def _log_skipped_sdk_memories(count: int) -> None:
|
||||
if count:
|
||||
logger.warning("sdk_memory_invalid_rows_skipped count=%d", count)
|
||||
@@ -5965,6 +6281,7 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
|
||||
|
||||
return (
|
||||
_config_store(app).sedimentation_enabled()
|
||||
and _embedding_writes_open(app)
|
||||
and (_memory_mode(app) == "lite" or _model_profile_store(app).active_identity() is not None)
|
||||
and _history_import_gate_released(app)
|
||||
and _source_writes_on(app, source_id)
|
||||
@@ -5979,6 +6296,74 @@ def _user_evolving(app: FastAPI, user_id: str) -> bool:
|
||||
return bool(callable(evolving) and evolving(user_id))
|
||||
|
||||
|
||||
def _embedding_switch(app: FastAPI) -> EmbeddingSwitchService:
|
||||
return cast(EmbeddingSwitchService, app.state.embedding_switch)
|
||||
|
||||
|
||||
def _embedding_writes_open(app: FastAPI) -> bool:
|
||||
"""False while an embedding switch or merge runs, or the startup scan has not decided.
|
||||
|
||||
Intake is not affected: conversations keep being queued, and sediment
|
||||
into the live space once the gate opens again.
|
||||
"""
|
||||
switch = getattr(app.state, "embedding_switch", None)
|
||||
return switch is None or switch.writes_open()
|
||||
|
||||
|
||||
def _embedding_switch_summary(app: FastAPI) -> dict[str, object] | None:
|
||||
switch = getattr(app.state, "embedding_switch", None)
|
||||
if switch is None:
|
||||
return None
|
||||
try:
|
||||
return cast(EmbeddingSwitchService, switch).summary()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
|
||||
|
||||
def _refuse_write_during_embedding_switch(app: FastAPI) -> None:
|
||||
"""Direct edits and deletes wait for the switch: 409, never a write into the old space."""
|
||||
if not _embedding_writes_open(app):
|
||||
raise HTTPException(status_code=409, detail="embedding_switch_in_progress")
|
||||
|
||||
|
||||
_EMBEDDING_PATCH_FIELDS = frozenset(
|
||||
{"embedding_model", "embedding_provider_kind", "embedding_base_url", "embedding_dimensions"}
|
||||
)
|
||||
|
||||
|
||||
def _refuse_embedding_edit_during_switch(
|
||||
app: FastAPI, profile_id: str, patch: ModelProfilePatch
|
||||
) -> None:
|
||||
"""The profiles a running switch reads from or commits to keep their embedding."""
|
||||
if not (_EMBEDDING_PATCH_FIELDS & patch.model_fields_set):
|
||||
return
|
||||
switch = getattr(app.state, "embedding_switch", None)
|
||||
if switch is None:
|
||||
return
|
||||
row = cast(EmbeddingSwitchService, switch).jobs.open_job()
|
||||
if row is None:
|
||||
return
|
||||
active = _model_profile_store(app).active_identity()
|
||||
if profile_id == row["target_profile_id"] or (active is not None and active[0] == profile_id):
|
||||
raise HTTPException(status_code=409, detail="embedding_switch_running")
|
||||
|
||||
|
||||
def _store_activation_preflight(app: FastAPI, profile_id: str) -> None:
|
||||
"""The checks `ModelProfileStore.activate` makes, for the empty-home path."""
|
||||
profile = _model_profile_store(app).require(profile_id, _secret_store(app))
|
||||
if profile.provider_kind in {ProviderKind.HY_DEFAULT, ProviderKind.ANTHROPIC}:
|
||||
raise ModelProfileUnavailableError("model_provider_not_supported")
|
||||
if not profile.embedding_model:
|
||||
raise ModelProfileUnavailableError("embedding_model_required")
|
||||
capabilities = profile.capabilities
|
||||
if not (
|
||||
capabilities.endpoint_reachable
|
||||
and capabilities.model_available
|
||||
and capabilities.embedding_available
|
||||
):
|
||||
raise ModelProfileUnavailableError("model_profile_probe_required")
|
||||
|
||||
|
||||
def _history_import_gate_released(app: FastAPI) -> bool:
|
||||
"""Hold first sedimentation until every accepted history import is terminal."""
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import re # noqa: E402
|
||||
import time # noqa: E402
|
||||
from collections.abc import Iterator, Sequence # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
from typing import TextIO # noqa: E402
|
||||
from typing import Any, TextIO # noqa: E402
|
||||
|
||||
from one_memory.config import load_settings # noqa: E402
|
||||
from one_memory.runtime_client import RuntimeStatus, inspect_runtime # noqa: E402
|
||||
@@ -609,6 +609,14 @@ def _sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
|
||||
else:
|
||||
state = "required"
|
||||
document = {"schema_version": 1, "authority": authority.value, "state": state}
|
||||
from one_memory.embedding_switch import read_open_job
|
||||
|
||||
# Informational. An unfinished embedding switch never makes a
|
||||
# migration required or blocks it: the upgrade installs, `sdk-migrate`
|
||||
# leaves the switch's tables alone, and the new Core resumes the job.
|
||||
switch = read_open_job(database_path)
|
||||
if switch is not None:
|
||||
document["embedding_switch"] = switch
|
||||
from one_memory.sdk_migration_log import write_migration_log
|
||||
|
||||
write_migration_log(
|
||||
@@ -616,7 +624,15 @@ def _sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
|
||||
writer="core-sdk-migration-status",
|
||||
event="status.probed",
|
||||
outcome="succeeded",
|
||||
details={"authority": document["authority"], "state": document["state"]},
|
||||
details={
|
||||
"authority": document["authority"],
|
||||
"state": document["state"],
|
||||
**(
|
||||
{"embedding_switch": document["embedding_switch"]}
|
||||
if "embedding_switch" in document
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
if as_json:
|
||||
stream.write(f"{json.dumps(document, ensure_ascii=False)}\n")
|
||||
@@ -696,6 +712,12 @@ def _refuse_unmigrated_home(data_dir: Path) -> None:
|
||||
raise SystemExit(SDK_MIGRATION_REQUIRED_EXIT)
|
||||
|
||||
|
||||
def _without_embedding_switch(settings: Any) -> Any:
|
||||
"""The maintenance Core's settings: it never resumes or runs a switch."""
|
||||
copy = getattr(settings, "model_copy", None)
|
||||
return copy(update={"embedding_switch_worker": False}) if callable(copy) else settings
|
||||
|
||||
|
||||
def serve(*, maintenance: bool = False) -> None:
|
||||
import uvicorn
|
||||
|
||||
@@ -740,7 +762,8 @@ def serve(*, maintenance: bool = False) -> None:
|
||||
if launcher is not None:
|
||||
ParentWatchdog(launcher).start()
|
||||
application = create_app(
|
||||
settings,
|
||||
# Only the serving Core runs or resumes an embedding switch.
|
||||
_without_embedding_switch(settings) if maintenance else settings,
|
||||
manage_runtime=not maintenance,
|
||||
)
|
||||
if maintenance:
|
||||
|
||||
@@ -45,6 +45,11 @@ class Settings(BaseSettings):
|
||||
model_request_limit: int | None = Field(default=None, ge=1, le=100_000)
|
||||
model_request_body_byte_limit: int = Field(default=24_576, ge=1)
|
||||
model_request_output_token_limit: int = Field(default=16_384, ge=1)
|
||||
# The embedding switch worker (and the startup merge). The maintenance
|
||||
# Core turns it off: only the serving Core may resume or run a switch.
|
||||
embedding_switch_worker: bool = True
|
||||
# How long a switch waits for model writes already past their claim.
|
||||
embedding_switch_drain_timeout_seconds: float = Field(default=600, gt=0)
|
||||
|
||||
@field_validator("development_origin")
|
||||
@classmethod
|
||||
|
||||
@@ -19,6 +19,7 @@ from one_memory.context_models import (
|
||||
SdkMemoryContextItem,
|
||||
SdkRawContextItem,
|
||||
)
|
||||
from one_memory.embedding_health import EMBEDDING_UNAVAILABLE, EmbeddingUnavailableError
|
||||
from one_memory.external_memory_access import external_access
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_READER
|
||||
from one_memory.hy_sdk_runtime import sdk_relevance_floor
|
||||
@@ -263,6 +264,10 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
)
|
||||
await record(final, timed_out=True)
|
||||
return final
|
||||
for lane in (raw_items, memory_items):
|
||||
if isinstance(lane, EmbeddingUnavailableError):
|
||||
# No lexical fallback: the query could not be embedded.
|
||||
raise HTTPException(503, EMBEDDING_UNAVAILABLE)
|
||||
partial = isinstance(raw_items, Exception) or isinstance(memory_items, Exception)
|
||||
raw_candidates = (
|
||||
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""What the embedding switch needs from the SDK's re-embed module, in Core terms.
|
||||
|
||||
The switch worker, the startup merge and the retention sweep only talk to a
|
||||
`ReembedBackend`. The production one (`hy_sdk_migration.SdkReembedBackend`)
|
||||
is the single place that calls `hy_memory`'s re-embed functions; tests use
|
||||
an in-memory one. Every space is named explicitly -- collection names and
|
||||
graph paths -- and nothing here derives a name from a provider or endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from one_memory.embedding_spaces import EmbeddingSpace, space_collection_names
|
||||
from one_memory.model_profiles import ModelProfileSnapshot
|
||||
|
||||
|
||||
class ReembedUnavailableError(RuntimeError):
|
||||
"""The installed SDK has no re-embed module (before hy-memory 2.0.10)."""
|
||||
|
||||
def __init__(self, code: str = "sdk_reembed_unavailable") -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
class ReembedCancelledError(RuntimeError):
|
||||
"""A cancel was seen at a batch boundary; every finished batch is written."""
|
||||
|
||||
|
||||
class EmbeddingWidthError(RuntimeError):
|
||||
"""The embedding service returned vectors of another width than the target's."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhysicalSpace:
|
||||
"""One space's collections, named explicitly."""
|
||||
|
||||
collection_base: str
|
||||
name_dims: int | None
|
||||
#: The width of the vectors in it, or to be written to it.
|
||||
dims: int | None
|
||||
model: str | None = None
|
||||
#: Explicit role -> collection name; None derives them from the base.
|
||||
collections: Mapping[str, str] | None = None
|
||||
|
||||
def names(self) -> dict[str, str]:
|
||||
if self.collections is not None:
|
||||
return dict(self.collections)
|
||||
return space_collection_names(self.collection_base, self.name_dims)
|
||||
|
||||
@classmethod
|
||||
def of(cls, space: EmbeddingSpace) -> PhysicalSpace:
|
||||
return cls(
|
||||
collection_base=space.collection_base,
|
||||
name_dims=space.name_dims,
|
||||
dims=space.dims if space.dims is not None else space.name_dims,
|
||||
model=space.model,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScannedSpace:
|
||||
"""A space found in the vector store, grouped from its collection names."""
|
||||
|
||||
collection_base: str
|
||||
name_dims: int | None
|
||||
#: The stored width when every non-empty role agrees.
|
||||
width: int | None
|
||||
counts: Mapping[str, int]
|
||||
collections: Mapping[str, str]
|
||||
per_user: Mapping[str, int] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def vectors(self) -> int:
|
||||
return sum(self.counts.values())
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, int]:
|
||||
return (self.collection_base, self.name_dims or 0)
|
||||
|
||||
def physical(self) -> PhysicalSpace:
|
||||
return PhysicalSpace(
|
||||
collection_base=self.collection_base,
|
||||
name_dims=self.name_dims,
|
||||
dims=self.width if self.width is not None else self.name_dims,
|
||||
collections=dict(self.collections),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReembedStep:
|
||||
"""Progress of one role (vectors) or table (graph)."""
|
||||
|
||||
phase: str
|
||||
role: str
|
||||
done: int
|
||||
total: int
|
||||
embedded: int = 0
|
||||
reused: int = 0
|
||||
kept: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VectorCheck:
|
||||
per_role: Mapping[str, Mapping[str, int]]
|
||||
#: Target records per metadata `user_id`.
|
||||
per_user: Mapping[str, int]
|
||||
problems: tuple[str, ...]
|
||||
warnings: tuple[str, ...] = ()
|
||||
self_recall_hits: int = 0
|
||||
self_recall_samples: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphCheck:
|
||||
ok: bool
|
||||
problems: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbeddingTarget:
|
||||
"""The embedding that fills a space: the profile to reach it, and its key."""
|
||||
|
||||
profile: ModelProfileSnapshot
|
||||
space: EmbeddingSpace
|
||||
api_key: str | None
|
||||
|
||||
|
||||
Cancel = Callable[[], bool]
|
||||
OnStep = Callable[[ReembedStep], None]
|
||||
|
||||
|
||||
class ReembedBackend(Protocol):
|
||||
def unavailable_reason(self) -> str | None:
|
||||
"""None when re-embedding works; otherwise the code that says why not."""
|
||||
|
||||
async def scan(self, *, per_user: bool = False) -> tuple[ScannedSpace, ...]:
|
||||
"""Every space with collections in the home's vector store. Read-only."""
|
||||
|
||||
async def diff(self, source: PhysicalSpace, target: PhysicalSpace) -> dict[str, int]:
|
||||
"""Per role, how many ids the source holds that the target does not."""
|
||||
|
||||
async def reembed(
|
||||
self,
|
||||
source: PhysicalSpace,
|
||||
target: PhysicalSpace,
|
||||
*,
|
||||
embedding: EmbeddingTarget,
|
||||
cancel: Cancel,
|
||||
on_step: OnStep,
|
||||
batch_size: int = 64,
|
||||
) -> None:
|
||||
"""Embed every source record the target lacks into the target. Target wins."""
|
||||
|
||||
async def verify(
|
||||
self, source: PhysicalSpace, target: PhysicalSpace, *, embedding: EmbeddingTarget
|
||||
) -> VectorCheck: ...
|
||||
|
||||
async def graph_vectors(self, path: Path) -> int | None:
|
||||
"""Embeddings a graph holds, or None when there is no graph there."""
|
||||
|
||||
async def reembed_graph(
|
||||
self,
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
embedding: EmbeddingTarget,
|
||||
target_dims: int,
|
||||
cancel: Cancel,
|
||||
on_step: OnStep,
|
||||
) -> None: ...
|
||||
|
||||
async def verify_graph(self, source: Path, target: Path, *, target_dims: int) -> GraphCheck: ...
|
||||
|
||||
async def drop(self, space: PhysicalSpace) -> None: ...
|
||||
|
||||
async def drop_graph(self, path: Path) -> None: ...
|
||||
@@ -0,0 +1,147 @@
|
||||
"""`embedding_unavailable`: a search that could not embed its query says so.
|
||||
|
||||
With a remote embedding model, every search embeds the query through that
|
||||
service. When the service is down, the SDK swallows the failure and answers
|
||||
with no hits, which reads as "you have no such memory". There is no lexical
|
||||
fallback (owner decision): the search fails with the explicit code
|
||||
`embedding_unavailable`, the API answers 503 with that code, and the App
|
||||
shows a toast.
|
||||
|
||||
The check is only made when a search came back empty or raised, so a working
|
||||
service costs nothing on searches that find something. It sends one tiny
|
||||
embedding request to the same endpoint, model and width the read client
|
||||
uses; the answer is remembered briefly so a burst of searches sends one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from one_memory.http_client import create_client
|
||||
from one_memory.hy_sdk_types import HySdkError
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
|
||||
|
||||
EMBEDDING_UNAVAILABLE = "embedding_unavailable"
|
||||
_OK_TTL_S = 30.0
|
||||
_FAILED_TTL_S = 10.0
|
||||
_TIMEOUT_S = 8.0
|
||||
|
||||
|
||||
def _no_transport() -> httpx.BaseTransport | None:
|
||||
return None
|
||||
|
||||
|
||||
#: Tests replace this to answer the check without a network.
|
||||
transport_factory: Callable[[], httpx.BaseTransport | None] = _no_transport
|
||||
|
||||
|
||||
class EmbeddingUnavailableError(HySdkError):
|
||||
"""The live space's embedding service did not answer; search cannot run."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(EMBEDDING_UNAVAILABLE)
|
||||
|
||||
|
||||
class EmbeddingHealth:
|
||||
"""Whether a remote embedding answers, remembered for a few seconds."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._known: dict[tuple[str, str, int | None], tuple[bool, float]] = {}
|
||||
|
||||
def available(
|
||||
self,
|
||||
profile: ModelProfileSnapshot,
|
||||
*,
|
||||
endpoint: str | None,
|
||||
name_dims: int | None,
|
||||
api_key: str | None,
|
||||
) -> bool:
|
||||
provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
if provider is ProviderKind.LOCAL_ONNX or endpoint is None:
|
||||
return True
|
||||
model = profile.embedding_model or ""
|
||||
key = (endpoint, model, name_dims)
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
known = self._known.get(key)
|
||||
if known is not None and known[1] > now:
|
||||
return known[0]
|
||||
ok = _answers(
|
||||
endpoint,
|
||||
model=model,
|
||||
name_dims=name_dims,
|
||||
api_key="ollama" if provider is ProviderKind.OLLAMA else api_key,
|
||||
)
|
||||
with self._lock:
|
||||
self._known[key] = (ok, now + (_OK_TTL_S if ok else _FAILED_TTL_S))
|
||||
return ok
|
||||
|
||||
def forget(self) -> None:
|
||||
with self._lock:
|
||||
self._known.clear()
|
||||
|
||||
|
||||
def _answers(endpoint: str, *, model: str, name_dims: int | None, api_key: str | None) -> bool:
|
||||
body: dict[str, Any] = {"model": model, "input": ["ping"]}
|
||||
if name_dims:
|
||||
body["dimensions"] = name_dims
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
transport = transport_factory()
|
||||
try:
|
||||
with create_client(
|
||||
timeout=_TIMEOUT_S,
|
||||
**({"transport": transport} if transport is not None else {}),
|
||||
) as client:
|
||||
response = client.post(f"{endpoint.rstrip('/')}/embeddings", json=body, headers=headers)
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
data = response.json().get("data")
|
||||
return bool(isinstance(data, list) and data and data[0].get("embedding"))
|
||||
except (httpx.HTTPError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def has_hits(result: object) -> bool:
|
||||
"""Whether an SDK search result holds any hit, whatever its scene's shape."""
|
||||
if not isinstance(result, Mapping):
|
||||
return bool(result)
|
||||
for key in ("memories", "facts", "experiences"):
|
||||
value = result.get(key)
|
||||
if isinstance(value, list) and value:
|
||||
return True
|
||||
if isinstance(value, Mapping) and any(
|
||||
isinstance(item, list) and item for item in value.values()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class EmbeddingGuardedClient:
|
||||
"""An SDK client whose `search` reports a dead embedding instead of nothing.
|
||||
|
||||
Everything else is the client's own attribute, unchanged.
|
||||
"""
|
||||
|
||||
def __init__(self, client: Any, available: Callable[[], bool]) -> None:
|
||||
self._client = client
|
||||
self._available = available
|
||||
|
||||
def search(self, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
result = self._client.search(*args, **kwargs)
|
||||
except Exception:
|
||||
if not self._available():
|
||||
raise EmbeddingUnavailableError() from None
|
||||
raise
|
||||
if not has_hits(result) and not self._available():
|
||||
raise EmbeddingUnavailableError()
|
||||
return result
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._client, name)
|
||||
@@ -0,0 +1,554 @@
|
||||
"""Which embedding space the home reads and writes: an explicit registry.
|
||||
|
||||
The SDK names its vector collections after `vector_store.collection_name`
|
||||
and the configured width, so the space used to follow whatever the active
|
||||
profile hashed to (`_collection_base`). Any change to that identity -- a
|
||||
probe recording the measured width, a new endpoint -- silently opened new,
|
||||
empty collections beside the old ones, and nothing carried the memories over.
|
||||
|
||||
The live space is pinned here instead, in the main Core database beside the
|
||||
model profiles, so a switch commits the new space and the profile that fills
|
||||
it in one transaction. The identity of a space is its embedding model and
|
||||
width only: a new endpoint or provider serving the same model at the same
|
||||
width keeps the space.
|
||||
|
||||
Existing homes keep their collections byte-for-byte. The first start registers
|
||||
the space the active profile (or the built-in model) already opened under its
|
||||
existing four-attribute name; only spaces created by a switch use the
|
||||
model-and-width name. A space whose width was never declared ("auto") keeps
|
||||
its unsuffixed name and only records the width it was measured at.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from one_memory.database import take_the_write_lock_first
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS, BUILTIN_EMBEDDING_MODEL
|
||||
from one_memory.memory_server.migrations import embedding_spaces_ddl
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
|
||||
|
||||
#: How long a space replaced by a switch or folded in by a merge is kept.
|
||||
RETIRED_SPACE_RETENTION = timedelta(days=14)
|
||||
|
||||
SpaceState = Literal["live", "building", "stray", "retired", "dropped"]
|
||||
SpaceNaming = Literal["legacy", "v2", "discovered"]
|
||||
|
||||
#: Every vector collection one space consists of, as the SDK names them.
|
||||
VECTOR_ROLES = (
|
||||
"memory",
|
||||
"entities",
|
||||
"coding_keys",
|
||||
"coding_keys_cv",
|
||||
"working_facts",
|
||||
"working_facts_cv",
|
||||
)
|
||||
|
||||
#: The graph directory of the first space; later spaces get their own.
|
||||
DEFAULT_GRAPH_DIR = "graph"
|
||||
|
||||
EMBEDDING_SPACES_SCHEMA = embedding_spaces_ddl()
|
||||
|
||||
|
||||
#: Bumped by every registry write in this process, so cached live rows are
|
||||
#: reread after a change made through any connection (a probe's, a commit's).
|
||||
_CHANGES = [0]
|
||||
|
||||
|
||||
def _changed() -> None:
|
||||
_CHANGES[0] += 1
|
||||
|
||||
|
||||
class EmbeddingSpaceError(RuntimeError):
|
||||
"""A registry refusal with a stable, product-facing code."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
class EmbeddingIdentity(BaseModel):
|
||||
"""What makes two vectors comparable: the model and the width.
|
||||
|
||||
`dims=None` is a width nobody declared or measured yet; it matches any
|
||||
width, because the collection it names was opened without one.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
model: str
|
||||
dims: int | None = None
|
||||
|
||||
def matches(self, other: EmbeddingIdentity) -> bool:
|
||||
if self.model != other.model:
|
||||
return False
|
||||
return self.dims is None or other.dims is None or self.dims == other.dims
|
||||
|
||||
|
||||
class EmbeddingSpace(BaseModel):
|
||||
"""One embedding space: the physical collections and graph one model fills."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
space_id: str
|
||||
state: SpaceState
|
||||
#: None only for a space found on disk that no registry row named.
|
||||
model: str | None = None
|
||||
#: The vector width stored in this space, once known.
|
||||
dims: int | None = None
|
||||
#: The width the collection names and embedding requests carry. None is
|
||||
#: an "auto" space: unsuffixed names and no `dimensions` request field.
|
||||
name_dims: int | None = None
|
||||
collection_base: str
|
||||
naming: SpaceNaming = "legacy"
|
||||
graph_dir: str | None = DEFAULT_GRAPH_DIR
|
||||
provider_kind: str | None = None
|
||||
#: The profile version whose embedding fills this space; None = built-in.
|
||||
profile_id: str | None = None
|
||||
profile_revision: int | None = None
|
||||
#: Vectors counted the last time the space was scanned.
|
||||
vectors: int | None = None
|
||||
created_at: str
|
||||
live_since: str | None = None
|
||||
retired_at: str | None = None
|
||||
keep_until: str | None = None
|
||||
dropped_at: str | None = None
|
||||
|
||||
@property
|
||||
def identity(self) -> EmbeddingIdentity | None:
|
||||
if self.model is None:
|
||||
return None
|
||||
return EmbeddingIdentity(model=self.model, dims=self.dims)
|
||||
|
||||
@property
|
||||
def builtin(self) -> bool:
|
||||
return self.provider_kind == ProviderKind.LOCAL_ONNX.value and self.profile_id is None
|
||||
|
||||
def collection_names(self) -> dict[str, str]:
|
||||
return space_collection_names(self.collection_base, self.name_dims)
|
||||
|
||||
|
||||
def space_collection_names(base: str, name_dims: int | None) -> dict[str, str]:
|
||||
"""The SDK's physical collection name per role for one space.
|
||||
|
||||
Mirrors `VectorStoreBase` (the `_<dims>` suffix), the entity store and the
|
||||
coding stores. The SDK's own `vector_space_names` is checked against this
|
||||
in the migration adapter when the installed SDK provides it.
|
||||
"""
|
||||
suffix = f"_{name_dims}" if name_dims else ""
|
||||
return {
|
||||
"memory": f"{base}{suffix}",
|
||||
"entities": f"{base}{suffix}_entities",
|
||||
"coding_keys": f"{base}_coding_keys{suffix}",
|
||||
"coding_keys_cv": f"{base}_coding_keys_cv{suffix}",
|
||||
"working_facts": f"{base}_working_facts{suffix}",
|
||||
"working_facts_cv": f"{base}_working_facts_cv{suffix}",
|
||||
}
|
||||
|
||||
|
||||
def embedding_identity(profile: ModelProfileSnapshot) -> EmbeddingIdentity:
|
||||
"""The embedding identity a profile asks for: model name and width only."""
|
||||
provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
local = provider is ProviderKind.LOCAL_ONNX
|
||||
model = profile.embedding_model or (BUILTIN_EMBEDDING_MODEL if local else None)
|
||||
if not model:
|
||||
raise EmbeddingSpaceError("hy_sdk_embedding_model_required")
|
||||
dims = profile.embedding_dimensions
|
||||
if local and dims is None:
|
||||
dims = BUILTIN_EMBEDDING_DIMENSIONS
|
||||
return EmbeddingIdentity(model=model, dims=dims)
|
||||
|
||||
|
||||
def v2_collection_base(identity: EmbeddingIdentity, generation: int = 0) -> str:
|
||||
"""The base name a switch gives a new space: model and width, nothing else."""
|
||||
digest = hashlib.sha256(f"{identity.model}\0{identity.dims or 'auto'}".encode()).hexdigest()
|
||||
base = f"memhub_e_{digest[:16]}"
|
||||
return f"{base}_g{generation}" if generation else base
|
||||
|
||||
|
||||
def graph_dir_for(space_id: str) -> str:
|
||||
return f"graph-{space_id[:8]}"
|
||||
|
||||
|
||||
def derived_space(profile: ModelProfileSnapshot) -> EmbeddingSpace:
|
||||
"""The space `profile` opened before the registry existed, under its old name.
|
||||
|
||||
Never stored as is: bootstrap stores it, and offline tools that run beside
|
||||
a home with no registry yet read through it.
|
||||
"""
|
||||
from one_memory.hy_sdk_runtime import legacy_collection_base
|
||||
|
||||
provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
identity = embedding_identity(profile)
|
||||
builtin = profile.profile_id == _BUILTIN_PROFILE_ID
|
||||
return EmbeddingSpace(
|
||||
space_id="derived",
|
||||
state="live",
|
||||
model=identity.model,
|
||||
dims=identity.dims,
|
||||
name_dims=identity.dims,
|
||||
collection_base=legacy_collection_base(profile),
|
||||
naming="legacy",
|
||||
graph_dir=DEFAULT_GRAPH_DIR,
|
||||
provider_kind=provider.value,
|
||||
profile_id=None if builtin else profile.profile_id,
|
||||
profile_revision=None if builtin else profile.revision,
|
||||
created_at=_now(),
|
||||
)
|
||||
|
||||
|
||||
_BUILTIN_PROFILE_ID = "builtin-retrieval"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _row(row: sqlite3.Row | Mapping[str, object]) -> EmbeddingSpace:
|
||||
return EmbeddingSpace.model_validate(dict(row))
|
||||
|
||||
|
||||
def ensure_schema(connection: sqlite3.Connection) -> None:
|
||||
connection.executescript(EMBEDDING_SPACES_SCHEMA)
|
||||
|
||||
|
||||
def registry_present(connection: sqlite3.Connection) -> bool:
|
||||
return (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='embedding_spaces'"
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def live_space_in(connection: sqlite3.Connection) -> EmbeddingSpace | None:
|
||||
if not registry_present(connection):
|
||||
return None
|
||||
previous = connection.row_factory
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
row = connection.execute("SELECT * FROM embedding_spaces WHERE state='live'").fetchone()
|
||||
finally:
|
||||
connection.row_factory = previous
|
||||
return _row(row) if row is not None else None
|
||||
|
||||
|
||||
def read_live_space(database_path: Path) -> EmbeddingSpace | None:
|
||||
"""The live space, read only, or None for a home with no registry yet."""
|
||||
if not database_path.is_file():
|
||||
return None
|
||||
with main_database(database_path).read(row_factory=sqlite3.Row) as connection:
|
||||
return live_space_in(connection)
|
||||
|
||||
|
||||
class EmbeddingSpaceStore:
|
||||
"""The `embedding_spaces` table of the main Core database."""
|
||||
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
self.database_path = database_path
|
||||
#: The live row, read once per change: every read and write asks for
|
||||
#: it. Only this process writes the registry while Core serves.
|
||||
self._live_cache: tuple[int, EmbeddingSpace] | None = None
|
||||
with self._connect(writing=True) as connection:
|
||||
ensure_schema(connection)
|
||||
|
||||
def invalidate(self) -> None:
|
||||
_changed()
|
||||
|
||||
@contextmanager
|
||||
def _connect(
|
||||
self, *, writing: bool = False, lock_first: bool = True
|
||||
) -> Iterator[sqlite3.Connection]:
|
||||
database = main_database(self.database_path)
|
||||
checkout = (
|
||||
database.write(row_factory=sqlite3.Row)
|
||||
if writing
|
||||
else database.read(row_factory=sqlite3.Row)
|
||||
)
|
||||
with checkout as connection, connection:
|
||||
if writing and lock_first:
|
||||
take_the_write_lock_first(connection)
|
||||
yield connection
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator[sqlite3.Connection]:
|
||||
"""One `BEGIN IMMEDIATE` on the main database, committed on success."""
|
||||
_changed()
|
||||
try:
|
||||
with self._connect(writing=True, lock_first=False) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
yield connection
|
||||
finally:
|
||||
_changed()
|
||||
|
||||
def live(self) -> EmbeddingSpace | None:
|
||||
generation = _CHANGES[0]
|
||||
cached = self._live_cache
|
||||
if cached is not None and cached[0] == generation:
|
||||
return cached[1]
|
||||
with self._connect() as connection:
|
||||
space = live_space_in(connection)
|
||||
self._live_cache = (generation, space) if space is not None else None
|
||||
return space
|
||||
|
||||
def require_live(self) -> EmbeddingSpace:
|
||||
space = self.live()
|
||||
if space is None:
|
||||
raise EmbeddingSpaceError("embedding_space_not_bootstrapped")
|
||||
return space
|
||||
|
||||
def get(self, space_id: str) -> EmbeddingSpace | None:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM embedding_spaces WHERE space_id=?", (space_id,)
|
||||
).fetchone()
|
||||
return _row(row) if row is not None else None
|
||||
|
||||
def list_spaces(self, *, include_dropped: bool = False) -> list[EmbeddingSpace]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM embedding_spaces "
|
||||
+ ("" if include_dropped else "WHERE state != 'dropped' ")
|
||||
+ "ORDER BY created_at, space_id"
|
||||
).fetchall()
|
||||
return [_row(row) for row in rows]
|
||||
|
||||
def bootstrap(self, active: ModelProfileSnapshot | None) -> EmbeddingSpace:
|
||||
"""Register the space this home already uses, once, under its existing name.
|
||||
|
||||
`active` is the active profile, or None for the built-in model. The
|
||||
collection name is derived exactly as before the registry, so nothing
|
||||
on disk moves.
|
||||
"""
|
||||
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
|
||||
|
||||
with self.transaction() as connection:
|
||||
existing = live_space_in(connection)
|
||||
if existing is not None:
|
||||
return existing
|
||||
derived = derived_space(active or builtin_retrieval_snapshot())
|
||||
space = derived.model_copy(
|
||||
update={"space_id": str(uuid4()), "live_since": derived.created_at}
|
||||
)
|
||||
_insert(connection, space)
|
||||
return space
|
||||
|
||||
def record_width(self, space_id: str, dims: int) -> EmbeddingSpace:
|
||||
"""A relabel: the space's stored width is now known. Names never change."""
|
||||
with self.transaction() as connection:
|
||||
record_width_in(connection, space_id, dims)
|
||||
space = self.get(space_id)
|
||||
if space is None: # pragma: no cover - the row was just updated
|
||||
raise EmbeddingSpaceError("embedding_space_not_found")
|
||||
return space
|
||||
|
||||
def record_vectors(self, counts: Mapping[str, int]) -> None:
|
||||
"""Cache the vector count per space id from the last scan (display only)."""
|
||||
if not counts:
|
||||
return
|
||||
_changed()
|
||||
with self._connect(writing=True) as connection:
|
||||
connection.executemany(
|
||||
"UPDATE embedding_spaces SET vectors=? WHERE space_id=?",
|
||||
[(count, space_id) for space_id, count in counts.items()],
|
||||
)
|
||||
|
||||
def register_stray(
|
||||
self,
|
||||
*,
|
||||
collection_base: str,
|
||||
name_dims: int | None,
|
||||
dims: int | None,
|
||||
vectors: int,
|
||||
) -> EmbeddingSpace:
|
||||
"""Name a non-empty space the scan found that no row described."""
|
||||
with self.transaction() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM embedding_spaces WHERE collection_base=? "
|
||||
"AND COALESCE(name_dims,0)=? AND state != 'dropped'",
|
||||
(collection_base, name_dims or 0),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return _row(row)
|
||||
space = EmbeddingSpace(
|
||||
space_id=str(uuid4()),
|
||||
state="stray",
|
||||
dims=dims,
|
||||
name_dims=name_dims,
|
||||
collection_base=collection_base,
|
||||
naming="discovered",
|
||||
graph_dir=None,
|
||||
vectors=vectors,
|
||||
created_at=_now(),
|
||||
)
|
||||
_insert(connection, space)
|
||||
return space
|
||||
|
||||
def mark_dropped(self, space_id: str) -> None:
|
||||
_changed()
|
||||
with self._connect(writing=True) as connection:
|
||||
connection.execute(
|
||||
"UPDATE embedding_spaces SET state='dropped', dropped_at=? "
|
||||
"WHERE space_id=? AND state IN ('retired','building')",
|
||||
(_now(), space_id),
|
||||
)
|
||||
|
||||
def expired(self, *, now: datetime | None = None) -> list[EmbeddingSpace]:
|
||||
moment = (now or datetime.now(UTC)).isoformat()
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM embedding_spaces WHERE state='retired' "
|
||||
"AND keep_until IS NOT NULL AND keep_until <= ?",
|
||||
(moment,),
|
||||
).fetchall()
|
||||
return [_row(row) for row in rows]
|
||||
|
||||
|
||||
def registry_for(profiles: object) -> EmbeddingSpaceStore | None:
|
||||
"""The registry beside a profile store, or None for a store with no database."""
|
||||
path = getattr(profiles, "database_path", None)
|
||||
return EmbeddingSpaceStore(path) if isinstance(path, Path) else None
|
||||
|
||||
|
||||
def _insert(connection: sqlite3.Connection, space: EmbeddingSpace) -> None:
|
||||
values = space.model_dump()
|
||||
columns = ", ".join(values)
|
||||
marks = ", ".join("?" for _ in values)
|
||||
connection.execute(
|
||||
f"INSERT INTO embedding_spaces ({columns}) VALUES ({marks})",
|
||||
tuple(values.values()),
|
||||
)
|
||||
|
||||
|
||||
def insert_space_in(connection: sqlite3.Connection, space: EmbeddingSpace) -> None:
|
||||
_changed()
|
||||
_insert(connection, space)
|
||||
|
||||
|
||||
def record_width_in(connection: sqlite3.Connection, space_id: str, dims: int) -> None:
|
||||
_changed()
|
||||
changed = connection.execute(
|
||||
"UPDATE embedding_spaces SET dims=? WHERE space_id=? AND (dims IS NULL OR dims=?)",
|
||||
(dims, space_id, dims),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
raise EmbeddingSpaceError("embedding_space_width_conflict")
|
||||
|
||||
|
||||
def space_in(connection: sqlite3.Connection, space_id: str) -> EmbeddingSpace | None:
|
||||
previous = connection.row_factory
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM embedding_spaces WHERE space_id=?", (space_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.row_factory = previous
|
||||
return _row(row) if row is not None else None
|
||||
|
||||
|
||||
def spaces_in(connection: sqlite3.Connection) -> list[EmbeddingSpace]:
|
||||
previous = connection.row_factory
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = connection.execute("SELECT * FROM embedding_spaces").fetchall()
|
||||
finally:
|
||||
connection.row_factory = previous
|
||||
return [_row(row) for row in rows]
|
||||
|
||||
|
||||
def set_state_in(
|
||||
connection: sqlite3.Connection,
|
||||
space_id: str,
|
||||
*,
|
||||
state: SpaceState,
|
||||
expected: tuple[SpaceState, ...],
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Move one space between states, refusing any it was not expected in."""
|
||||
_changed()
|
||||
moment = now or datetime.now(UTC)
|
||||
updates: dict[str, object] = {"state": state}
|
||||
if state == "live":
|
||||
updates["live_since"] = moment.isoformat()
|
||||
elif state == "retired":
|
||||
updates["retired_at"] = moment.isoformat()
|
||||
updates["keep_until"] = (moment + RETIRED_SPACE_RETENTION).isoformat()
|
||||
elif state == "dropped":
|
||||
updates["dropped_at"] = moment.isoformat()
|
||||
assignments = ", ".join(f"{column}=?" for column in updates)
|
||||
marks = ",".join("?" for _ in expected)
|
||||
changed = connection.execute(
|
||||
f"UPDATE embedding_spaces SET {assignments} WHERE space_id=? AND state IN ({marks})",
|
||||
(*updates.values(), space_id, *expected),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
raise EmbeddingSpaceError("embedding_space_state_changed")
|
||||
|
||||
|
||||
def next_v2_base(
|
||||
connection: sqlite3.Connection,
|
||||
identity: EmbeddingIdentity,
|
||||
*,
|
||||
stored_names: frozenset[str],
|
||||
) -> str:
|
||||
"""A base for a new space no registry row and no stored collection uses.
|
||||
|
||||
Switching back to a model whose retired space is still kept gets a fresh
|
||||
`_g<n>` base, so stale retired vectors are never mixed with fresh ones.
|
||||
"""
|
||||
used = {space.collection_base for space in spaces_in(connection)}
|
||||
generation = 0
|
||||
while True:
|
||||
base = v2_collection_base(identity, generation)
|
||||
names = space_collection_names(base, identity.dims).values()
|
||||
if base not in used and not any(name in stored_names for name in names):
|
||||
return base
|
||||
generation += 1
|
||||
|
||||
|
||||
def probe_width_allowed(
|
||||
connection: sqlite3.Connection,
|
||||
profile_id: str,
|
||||
width: int,
|
||||
*,
|
||||
data_dir: Path,
|
||||
) -> bool:
|
||||
"""Whether a probe may record `width` on a profile without moving the space.
|
||||
|
||||
A profile that fills the live space (the active one, or the one the space
|
||||
was built with) may record its measured width only when it is the
|
||||
space's: equal to the width already known, or -- for an "auto" space
|
||||
whose width was never recorded -- equal to the width Chroma stored, or
|
||||
any width when nothing is stored yet. That is a relabel; the names never
|
||||
change. Any other width needs a switch (B2: it used to silently open a
|
||||
new, empty collection). Every other profile records what it measured.
|
||||
"""
|
||||
live = live_space_in(connection)
|
||||
if live is None:
|
||||
return True
|
||||
active = connection.execute(
|
||||
"SELECT profile_id FROM model_profiles WHERE is_active=1"
|
||||
).fetchone()
|
||||
fills_live = live.profile_id == profile_id or (active is not None and active[0] == profile_id)
|
||||
if not fills_live:
|
||||
return True
|
||||
if live.dims is not None:
|
||||
return live.dims == width
|
||||
from one_memory.sdk_vector_reattach import stored_collection_width
|
||||
|
||||
stored = stored_collection_width(data_dir, live.collection_names()["memory"])
|
||||
if stored is not None and stored != width:
|
||||
return False
|
||||
record_width_in(connection, live.space_id, width)
|
||||
return True
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
||||
"""HTTP surface of the embedding switch: spaces, plans, jobs, cleanup.
|
||||
|
||||
The contract the App and the CLI build on is
|
||||
`memhub/docs/contracts/embedding-switch-v1.md`. Errors keep Core's shape,
|
||||
`{"detail": "<code>"}`; a refusal that computed a plan also carries it as
|
||||
`"plan"`, so a client can show the confirmation without asking again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from one_memory.embedding_backend import ReembedUnavailableError
|
||||
from one_memory.embedding_spaces import EmbeddingSpaceError
|
||||
from one_memory.embedding_switch import (
|
||||
EmbeddingSwitchError,
|
||||
EmbeddingSwitchPlan,
|
||||
EmbeddingSwitchService,
|
||||
)
|
||||
|
||||
#: HTTP status per refusal code; anything not listed is a 409.
|
||||
_STATUS = {
|
||||
"model_profile_not_found": 404,
|
||||
"embedding_switch_not_found": 404,
|
||||
"embedding_space_not_found": 404,
|
||||
"sdk_reembed_unavailable": 503,
|
||||
"secure_storage_unavailable": 503,
|
||||
"insufficient_disk_space": 507,
|
||||
}
|
||||
|
||||
|
||||
class EmbeddingSwitchPlanRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
profile_id: str = Field(min_length=1, max_length=128)
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class EmbeddingSwitchStartRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
profile_id: str = Field(min_length=1, max_length=128)
|
||||
expected_revision: int = Field(ge=1)
|
||||
plan_token: str = Field(min_length=1, max_length=128)
|
||||
requested_by: str = Field(default="app", pattern=r"^(app|cli)$")
|
||||
|
||||
|
||||
def refusal(code: str, *, plan: EmbeddingSwitchPlan | None = None) -> JSONResponse:
|
||||
content: dict[str, Any] = {"detail": code}
|
||||
if plan is not None:
|
||||
content["plan"] = plan.model_dump(mode="json")
|
||||
return JSONResponse(status_code=_STATUS.get(code, 409), content=content)
|
||||
|
||||
|
||||
def _service(request: Request) -> EmbeddingSwitchService:
|
||||
return cast(EmbeddingSwitchService, request.app.state.embedding_switch)
|
||||
|
||||
|
||||
def embedding_switch_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
router = APIRouter(dependencies=dependencies)
|
||||
|
||||
@router.get("/api/v1/embedding-spaces")
|
||||
async def list_embedding_spaces(request: Request) -> dict[str, object]:
|
||||
"""The registry and what the vector store holds, space by space."""
|
||||
service = _service(request)
|
||||
spaces = await asyncio.to_thread(service.spaces.list_spaces)
|
||||
live = next((space for space in spaces if space.state == "live"), None)
|
||||
scanned: list[dict[str, object]] | None = None
|
||||
scan_error = service.backend.unavailable_reason()
|
||||
if scan_error is None:
|
||||
try:
|
||||
found = await service.backend.scan()
|
||||
except Exception as error: # noqa: BLE001 - the registry still answers
|
||||
scan_error = getattr(error, "code", None) or "embedding_space_scan_failed"
|
||||
else:
|
||||
known = {(space.collection_base, space.name_dims or 0): space for space in spaces}
|
||||
scanned = [
|
||||
{
|
||||
"collection_base": item.collection_base,
|
||||
"name_dims": item.name_dims,
|
||||
"width": item.width,
|
||||
"vectors": item.vectors,
|
||||
"counts": dict(item.counts),
|
||||
"space_id": known[item.key].space_id if item.key in known else None,
|
||||
"state": known[item.key].state if item.key in known else "unregistered",
|
||||
}
|
||||
for item in found
|
||||
]
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"live_space_id": live.space_id if live is not None else None,
|
||||
"spaces": [space.model_dump(mode="json") for space in spaces],
|
||||
"scanned": scanned,
|
||||
"scan_error": scan_error,
|
||||
}
|
||||
|
||||
@router.delete("/api/v1/embedding-spaces/{space_id}", response_model=None)
|
||||
async def delete_embedding_space(space_id: str, request: Request) -> Any:
|
||||
"""Clean up a retired space now instead of after its 14 days."""
|
||||
try:
|
||||
space = await _service(request).drop_space(space_id)
|
||||
except (EmbeddingSwitchError, EmbeddingSpaceError, ReembedUnavailableError) as error:
|
||||
return refusal(error.code)
|
||||
except RuntimeError as error:
|
||||
if "held open" in str(error):
|
||||
return refusal("embedding_space_in_use")
|
||||
raise
|
||||
return {"schema_version": 1, "space": space.model_dump(mode="json")}
|
||||
|
||||
@router.post("/api/v1/embedding-switches/plan", response_model=None)
|
||||
async def plan_embedding_switch(value: EmbeddingSwitchPlanRequest, request: Request) -> Any:
|
||||
try:
|
||||
plan = await _service(request).plan(
|
||||
value.profile_id, expected_revision=value.expected_revision
|
||||
)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code, plan=error.plan)
|
||||
return plan.model_dump(mode="json")
|
||||
|
||||
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
|
||||
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
|
||||
try:
|
||||
job = await _service(request).start_switch(
|
||||
value.profile_id,
|
||||
expected_revision=value.expected_revision,
|
||||
plan_token=value.plan_token,
|
||||
requested_by=value.requested_by,
|
||||
)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code, plan=error.plan)
|
||||
return job.model_dump(mode="json")
|
||||
|
||||
@router.get("/api/v1/embedding-switches/current")
|
||||
async def current_embedding_switch(request: Request, response: Response) -> dict[str, object]:
|
||||
"""Polled by the loading screen, the banner and `memhub models switch status`."""
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await asyncio.to_thread(_service(request).current)
|
||||
|
||||
@router.get("/api/v1/embedding-switches/{job_id}", response_model=None)
|
||||
async def get_embedding_switch(job_id: str, request: Request) -> Any:
|
||||
job = await asyncio.to_thread(_service(request).get_job, job_id)
|
||||
if job is None:
|
||||
return refusal("embedding_switch_not_found")
|
||||
return job.model_dump(mode="json")
|
||||
|
||||
@router.post("/api/v1/embedding-switches/{job_id}/cancel", response_model=None)
|
||||
async def cancel_embedding_switch(job_id: str, request: Request) -> Any:
|
||||
try:
|
||||
job = await asyncio.to_thread(_service(request).cancel, job_id)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code)
|
||||
return job.model_dump(mode="json")
|
||||
|
||||
return router
|
||||
@@ -94,11 +94,15 @@ def inspect_profile_graph_width(
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
mode: str | None = None,
|
||||
graph_dir: str = "graph",
|
||||
) -> GraphWidthMismatch | None:
|
||||
"""Inspect the graph path and width selected by the active profile."""
|
||||
"""Inspect the graph path and width selected by the active profile.
|
||||
|
||||
`graph_dir` is the live embedding space's graph (`embedding_spaces`).
|
||||
"""
|
||||
del api_key
|
||||
return _inspect_graph_width(
|
||||
_profile_graph_path(data_dir, mode),
|
||||
_profile_graph_path(data_dir, mode, graph_dir),
|
||||
_profile_embedding_width(profile),
|
||||
)
|
||||
|
||||
@@ -110,11 +114,12 @@ def decide_profile_graph_width(
|
||||
data_dir: Path,
|
||||
decision: GraphWidthDecision,
|
||||
mode: str | None = None,
|
||||
graph_dir: str = "graph",
|
||||
) -> GraphWidthOutcome:
|
||||
"""Apply the user's decision to the active profile's graph."""
|
||||
del api_key
|
||||
config = _GraphAdminConfig(
|
||||
graph_store=_GraphStoreConfig(str(_profile_graph_path(data_dir, mode))),
|
||||
graph_store=_GraphStoreConfig(str(_profile_graph_path(data_dir, mode, graph_dir))),
|
||||
embedder=_EmbeddingWidthConfig(_profile_embedding_width(profile)),
|
||||
)
|
||||
return apply_graph_width_decision(
|
||||
@@ -204,6 +209,12 @@ _GRAPH_WIDTH_CACHE: dict[str, int | None] = {}
|
||||
_GRAPH_WIDTH_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def forget_graph_widths() -> None:
|
||||
"""Drop every remembered width: an embedding switch moved the graph."""
|
||||
with _GRAPH_WIDTH_LOCK:
|
||||
_GRAPH_WIDTH_CACHE.clear()
|
||||
|
||||
|
||||
def _graph_embedding_width(db_path: Path) -> int | None:
|
||||
"""Read the Memory embedding width directly from Kuzu's schema."""
|
||||
try:
|
||||
@@ -252,9 +263,9 @@ def _inspect_graph_width(
|
||||
)
|
||||
|
||||
|
||||
def _profile_graph_path(data_dir: Path, mode: str | None = None) -> Path:
|
||||
def _profile_graph_path(data_dir: Path, mode: str | None = None, graph_dir: str = "graph") -> Path:
|
||||
ultra = mode == "ultra" if mode is not None else graph_store_enabled()
|
||||
name = "graph" if ultra else "graph-disabled"
|
||||
name = graph_dir if ultra else "graph-disabled"
|
||||
return data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / name
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.9"
|
||||
HY_MEMORY_VERSION = "2.0.10"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
|
||||
@@ -3,20 +3,26 @@
|
||||
Runtime product code must use public ``HyMemoryClient`` methods. The startup
|
||||
migration boundary is the isolated exception: all writers are stopped, so it
|
||||
may copy, restore and repair SDK-owned local stores.
|
||||
|
||||
The embedding switch is the second such boundary: model writes are paused
|
||||
while it re-embeds one space into another (`SdkReembedBackend`), so it may
|
||||
read and fill SDK collections through the SDK's re-embed module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import closing
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hy_memory import ( # type: ignore[import-untyped]
|
||||
import hy_memory # type: ignore[import-untyped]
|
||||
from hy_memory import (
|
||||
DataPlaneRestore,
|
||||
DataPlaneSnapshot,
|
||||
restore_local_data_plane,
|
||||
@@ -497,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
|
||||
CodingMemoryStore,
|
||||
)
|
||||
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.9 is pinned exactly).
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.10 is pinned exactly).
|
||||
store = CodingMemoryStore.__new__(CodingMemoryStore)
|
||||
store._db_path = str(path)
|
||||
store._conn = None
|
||||
@@ -511,3 +517,256 @@ def _ensure_coding_schema(path: Path) -> None:
|
||||
def _batches[T](values: list[T] | tuple[T, ...]) -> Iterator[tuple[T, ...]]:
|
||||
for start in range(0, len(values), _BATCH):
|
||||
yield tuple(values[start : start + _BATCH])
|
||||
|
||||
|
||||
# The SDK functions the embedding switch needs. They arrived in hy-memory
|
||||
# 2.0.10; an older SDK reports `sdk_reembed_unavailable` instead of failing.
|
||||
_REEMBED_API = (
|
||||
"VectorSpace",
|
||||
"scan_vector_spaces",
|
||||
"diff_spaces",
|
||||
"reembed_vector_space",
|
||||
"verify_reembed",
|
||||
"drop_vector_space",
|
||||
"reembed_graph",
|
||||
"verify_graph_reembed",
|
||||
"drop_graph",
|
||||
"ReembedCancelled",
|
||||
)
|
||||
|
||||
|
||||
class SdkReembedBackend:
|
||||
"""`ReembedBackend` over `hy_memory`'s re-embed module, for one home.
|
||||
|
||||
Runs beside live SDK clients: the vector functions open Chroma with the
|
||||
SDK's own settings, so they share its client system. The graph functions
|
||||
refuse a graph a live client holds open; the switch closes them first.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.data_dir = data_dir
|
||||
self.root = data_dir.resolve() / "hy-memory" / HY_MEMORY_NAMESPACE
|
||||
self.persist_directory = self.root / "vector"
|
||||
self._chroma_opened = False
|
||||
|
||||
def unavailable_reason(self) -> str | None:
|
||||
missing = [name for name in _REEMBED_API if not hasattr(hy_memory, name)]
|
||||
return "sdk_reembed_unavailable" if missing else None
|
||||
|
||||
def _sdk(self) -> Any:
|
||||
from one_memory.embedding_backend import ReembedUnavailableError
|
||||
|
||||
reason = self.unavailable_reason()
|
||||
if reason is not None:
|
||||
raise ReembedUnavailableError(reason)
|
||||
return hy_memory
|
||||
|
||||
def _open_chroma_once(self) -> None:
|
||||
"""Open Chroma once under the client-construction lock.
|
||||
|
||||
Every later open beside the Runtime finds that system. Two first
|
||||
opens race over Chroma's unlocked cache, and a client under
|
||||
construction lost its stores that way (see `hy_sdk_runtime`).
|
||||
"""
|
||||
if self._chroma_opened or not self.persist_directory.is_dir():
|
||||
return
|
||||
import chromadb
|
||||
|
||||
from one_memory.hy_sdk_runtime import first_store_open
|
||||
|
||||
with first_store_open(self.data_dir):
|
||||
chromadb.PersistentClient(path=str(self.persist_directory))
|
||||
self._chroma_opened = True
|
||||
|
||||
def _vector_space(self, space: Any) -> Any:
|
||||
return self._sdk().VectorSpace(
|
||||
base_name=space.collection_base,
|
||||
dims=space.dims,
|
||||
persist_directory=str(self.persist_directory),
|
||||
model=space.model,
|
||||
collections=space.names(),
|
||||
)
|
||||
|
||||
async def scan(self, *, per_user: bool = False) -> tuple[Any, ...]:
|
||||
from one_memory.embedding_backend import ScannedSpace
|
||||
|
||||
if not self.persist_directory.is_dir():
|
||||
return ()
|
||||
await asyncio.to_thread(self._open_chroma_once)
|
||||
found = await self._sdk().scan_vector_spaces(str(self.persist_directory), per_user=per_user)
|
||||
return tuple(
|
||||
ScannedSpace(
|
||||
collection_base=str(item.base_name),
|
||||
name_dims=item.dims,
|
||||
width=item.width,
|
||||
counts={role: int(entry.count) for role, entry in item.roles.items()},
|
||||
collections={role: str(entry.name) for role, entry in item.roles.items()},
|
||||
per_user=dict(item.per_user) if per_user else {},
|
||||
)
|
||||
for item in found
|
||||
)
|
||||
|
||||
async def diff(self, source: Any, target: Any) -> dict[str, int]:
|
||||
await asyncio.to_thread(self._open_chroma_once)
|
||||
result = await self._sdk().diff_spaces(
|
||||
self._vector_space(source), self._vector_space(target)
|
||||
)
|
||||
return {role: len(ids) for role, ids in result.missing.items()}
|
||||
|
||||
async def reembed(
|
||||
self,
|
||||
source: Any,
|
||||
target: Any,
|
||||
*,
|
||||
embedding: Any,
|
||||
cancel: Any,
|
||||
on_step: Any,
|
||||
batch_size: int = 64,
|
||||
) -> None:
|
||||
from one_memory.embedding_backend import ReembedStep
|
||||
|
||||
await asyncio.to_thread(self._open_chroma_once)
|
||||
sdk = self._sdk()
|
||||
|
||||
def progress(item: Any) -> None:
|
||||
on_step(
|
||||
ReembedStep(
|
||||
phase="vectors",
|
||||
role=str(item.role),
|
||||
done=int(item.done),
|
||||
total=int(item.total),
|
||||
embedded=int(item.embedded),
|
||||
reused=int(item.reused),
|
||||
kept=int(getattr(item, "kept", 0)),
|
||||
)
|
||||
)
|
||||
|
||||
with self._translated():
|
||||
await sdk.reembed_vector_space(
|
||||
self._vector_space(source),
|
||||
self._vector_space(target),
|
||||
embedding=self._embedding_service(embedding),
|
||||
batch_size=batch_size,
|
||||
cancel=cancel,
|
||||
on_progress=progress,
|
||||
# The target wins: an id it already holds is never touched, so
|
||||
# a resumed run continues and a merge never overwrites live data.
|
||||
skip_existing=True,
|
||||
)
|
||||
|
||||
async def verify(self, source: Any, target: Any, *, embedding: Any) -> Any:
|
||||
from one_memory.embedding_backend import VectorCheck
|
||||
|
||||
await asyncio.to_thread(self._open_chroma_once)
|
||||
with self._translated():
|
||||
result = await self._sdk().verify_reembed(
|
||||
self._vector_space(source),
|
||||
self._vector_space(target),
|
||||
embedding=self._embedding_service(embedding),
|
||||
)
|
||||
return VectorCheck(
|
||||
per_role={role: dict(values) for role, values in result.per_role.items()},
|
||||
per_user=dict(result.per_user),
|
||||
problems=tuple(result.problems),
|
||||
warnings=tuple(result.warnings),
|
||||
self_recall_hits=int(result.self_recall_hits),
|
||||
self_recall_samples=int(result.self_recall_samples),
|
||||
)
|
||||
|
||||
async def graph_vectors(self, path: Path) -> int | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
# Not re-exported at the package root; the module is the SDK's own.
|
||||
plan = await self._sdk().reembed.plan_graph_reembed(path)
|
||||
return int(plan.vectors)
|
||||
|
||||
async def reembed_graph(
|
||||
self,
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
embedding: Any,
|
||||
target_dims: int,
|
||||
cancel: Any,
|
||||
on_step: Any,
|
||||
) -> None:
|
||||
from one_memory.embedding_backend import ReembedStep
|
||||
|
||||
def progress(item: Any) -> None:
|
||||
on_step(
|
||||
ReembedStep(
|
||||
phase="graph",
|
||||
role=str(item.table),
|
||||
done=int(item.done),
|
||||
total=int(item.total),
|
||||
embedded=int(item.embedded),
|
||||
)
|
||||
)
|
||||
|
||||
with self._translated():
|
||||
await self._sdk().reembed_graph(
|
||||
source,
|
||||
target,
|
||||
embedding=self._embedding_service(embedding),
|
||||
target_dims=target_dims,
|
||||
cancel=cancel,
|
||||
on_progress=progress,
|
||||
# A resumed switch rebuilds the copy from the untouched source.
|
||||
overwrite=True,
|
||||
)
|
||||
|
||||
async def verify_graph(self, source: Path, target: Path, *, target_dims: int) -> Any:
|
||||
from one_memory.embedding_backend import GraphCheck
|
||||
|
||||
result = await self._sdk().verify_graph_reembed(source, target, target_dims=target_dims)
|
||||
return GraphCheck(ok=bool(result.ok), problems=tuple(result.problems))
|
||||
|
||||
async def drop(self, space: Any) -> None:
|
||||
await asyncio.to_thread(self._open_chroma_once)
|
||||
await self._sdk().drop_vector_space(self._vector_space(space))
|
||||
|
||||
async def drop_graph(self, path: Path) -> None:
|
||||
await self._sdk().drop_graph(path)
|
||||
|
||||
def _embedding_service(self, embedding: Any) -> Any:
|
||||
"""The service that computes a target space's vectors.
|
||||
|
||||
The built-in model is MemHub's ONNX service, exactly what the Runtime
|
||||
client embeds with. A remote model gets the SDK's own service, built
|
||||
from the same configuration the Runtime client would use.
|
||||
"""
|
||||
from one_memory.hy_sdk_runtime import (
|
||||
LocalOnnxEmbeddingService,
|
||||
build_sdk_config,
|
||||
retrieval_view,
|
||||
)
|
||||
from one_memory.model_profiles import ProviderKind
|
||||
|
||||
profile = embedding.profile
|
||||
provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
if provider is ProviderKind.LOCAL_ONNX:
|
||||
return LocalOnnxEmbeddingService()
|
||||
from hy_memory.core.embed_service import EmbedService # type: ignore[import-untyped]
|
||||
|
||||
return EmbedService(
|
||||
# The retrieval view: re-embedding never needs the generation model.
|
||||
build_sdk_config(
|
||||
retrieval_view(profile),
|
||||
embedding.space,
|
||||
api_key=embedding.api_key,
|
||||
data_dir=self.data_dir,
|
||||
)
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _translated(self) -> Iterator[None]:
|
||||
"""SDK refusals as the Core errors the switch classifies."""
|
||||
from one_memory.embedding_backend import EmbeddingWidthError, ReembedCancelledError
|
||||
|
||||
sdk = self._sdk()
|
||||
try:
|
||||
yield
|
||||
except sdk.ReembedCancelled:
|
||||
raise ReembedCancelledError("embedding_switch_cancelled") from None
|
||||
except hy_memory.EmbeddingDimensionMismatchError as error:
|
||||
raise EmbeddingWidthError("embedding_dimensions_mismatch") from error
|
||||
@@ -11,10 +11,11 @@ import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import Callable, Coroutine, Iterable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
from typing import Any, ClassVar, Literal, cast
|
||||
|
||||
from hy_memory import ( # type: ignore[import-untyped]
|
||||
AmbiguousMemoryIdError as AmbiguousMemoryIdError, # re-export for api.py
|
||||
@@ -29,6 +30,13 @@ from one_memory.embedding_assets import (
|
||||
BUILTIN_EMBEDDING_DIMENSIONS,
|
||||
BUILTIN_EMBEDDING_MODEL,
|
||||
)
|
||||
from one_memory.embedding_spaces import (
|
||||
DEFAULT_GRAPH_DIR,
|
||||
EmbeddingSpace,
|
||||
EmbeddingSpaceError,
|
||||
derived_space,
|
||||
embedding_identity,
|
||||
)
|
||||
from one_memory.hy_graph_admin import (
|
||||
GraphWidthMismatchError,
|
||||
effective_memory_mode,
|
||||
@@ -37,6 +45,12 @@ from one_memory.hy_graph_admin import (
|
||||
from one_memory.hy_sdk_types import HySdkRuntimeError
|
||||
from one_memory.local_embedding import LocalEmbedder
|
||||
from one_memory.log_file import confine_hy_sdk_logging
|
||||
from one_memory.memory_features import (
|
||||
FEATURES,
|
||||
FEATURES_BY_NAME,
|
||||
FeatureOverrides,
|
||||
feature_overrides,
|
||||
)
|
||||
from one_memory.model_profiles import (
|
||||
ModelProfileSnapshot,
|
||||
ProviderKind,
|
||||
@@ -48,6 +62,10 @@ BUILTIN_RETRIEVAL_PROFILE_ID = "builtin-retrieval"
|
||||
_LOCAL_ONNX_PLACEHOLDER_URL = "http://127.0.0.1/local-onnx"
|
||||
DEFAULT_RELEVANCE_FLOOR = 0.35
|
||||
|
||||
#: The SDK's memory modes. A mode never changes a collection name: the space
|
||||
#: names the collections, the mode only which stores the client opens.
|
||||
MemoryMode = Literal["lite", "pro", "ultra"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RuntimeEntry:
|
||||
@@ -170,33 +188,73 @@ class SdkClientHandle:
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
space: EmbeddingSpace | None = None,
|
||||
mode: str | None = None,
|
||||
features: FeatureOverrides = (),
|
||||
) -> None:
|
||||
self._profile = profile
|
||||
self._api_key = api_key
|
||||
self._data_dir = data_dir
|
||||
self._http_transport_factory = http_transport_factory
|
||||
self._space = space
|
||||
#: The SDK mode this handle's client is built with, fixed for its life.
|
||||
self.mode = mode or effective_memory_mode()
|
||||
#: The SDK features set on its config (`memory_features`), fixed likewise.
|
||||
self.features = features
|
||||
self._lease: ClientLease | None = None
|
||||
self._constructing: asyncio.Future[ClientLease] | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def profile(self) -> ModelProfileSnapshot:
|
||||
return self._profile
|
||||
|
||||
@property
|
||||
def space(self) -> EmbeddingSpace | None:
|
||||
return self._space
|
||||
|
||||
@property
|
||||
def api_key(self) -> str | None:
|
||||
return self._api_key
|
||||
|
||||
async def client(self) -> HyMemoryClient:
|
||||
if self._lease is not None:
|
||||
return self._lease.client
|
||||
async with self._lock:
|
||||
if self._lease is None:
|
||||
self._lease = await asyncio.to_thread(
|
||||
acquire_client,
|
||||
self._profile,
|
||||
api_key=self._api_key,
|
||||
data_dir=self._data_dir,
|
||||
http_transport_factory=self._http_transport_factory,
|
||||
mode=self.mode,
|
||||
constructing = asyncio.ensure_future(
|
||||
asyncio.to_thread(
|
||||
_acquire_for_handle,
|
||||
self._profile,
|
||||
self._space,
|
||||
api_key=self._api_key,
|
||||
data_dir=self._data_dir,
|
||||
http_transport_factory=self._http_transport_factory,
|
||||
mode=self.mode,
|
||||
features=self.features,
|
||||
)
|
||||
)
|
||||
self._constructing = constructing
|
||||
try:
|
||||
self._lease = await asyncio.shield(constructing)
|
||||
except asyncio.CancelledError:
|
||||
# The construction thread runs on. Keep what it builds, so
|
||||
# `close` releases it instead of leaving its runtime open.
|
||||
constructing.add_done_callback(self._keep)
|
||||
raise
|
||||
return self._lease.client
|
||||
|
||||
def _keep(self, constructing: asyncio.Future[ClientLease]) -> None:
|
||||
if constructing.cancelled() or constructing.exception() is not None:
|
||||
return
|
||||
if self._lease is None:
|
||||
self._lease = constructing.result()
|
||||
|
||||
async def close(self) -> None:
|
||||
constructing = self._constructing
|
||||
if constructing is not None and not constructing.done():
|
||||
# A construction a cancelled caller left behind: wait for its lease.
|
||||
await asyncio.gather(constructing, return_exceptions=True)
|
||||
async with self._lock:
|
||||
lease, self._lease = self._lease, None
|
||||
if lease is not None:
|
||||
@@ -259,28 +317,70 @@ def sdk_relevance_floor() -> float:
|
||||
return min(1.0, max(0.0, value))
|
||||
|
||||
|
||||
def sdk_feature_defaults() -> dict[str, bool]:
|
||||
"""Each `memory_features` feature's SDK default, as a fresh config has it.
|
||||
|
||||
That is the SDK's code default unless the process environment sets the
|
||||
SDK's own variable (`FeatureSpec.env`), exactly what an unset feature
|
||||
leaves on a client.
|
||||
"""
|
||||
config = MemoryConfig()
|
||||
return {
|
||||
spec.name: bool(getattr(getattr(config, spec.section), spec.field)) for spec in FEATURES
|
||||
}
|
||||
|
||||
|
||||
def apply_sdk_features(
|
||||
config: MemoryConfig, features: Mapping[str, bool] | Iterable[tuple[str, bool]]
|
||||
) -> MemoryConfig:
|
||||
"""Set each feature a person chose on its `MemoryConfig` field.
|
||||
|
||||
Every exposed feature is a config field the SDK reads from the config at
|
||||
call time, never from `os.environ`, so this is per client and
|
||||
deterministic. Nothing here touches `vector_store`, so the collection
|
||||
identity never depends on a feature.
|
||||
"""
|
||||
values = dict(features.items() if isinstance(features, Mapping) else features)
|
||||
for name, value in feature_overrides(values):
|
||||
spec = FEATURES_BY_NAME[name]
|
||||
setattr(getattr(config, spec.section), spec.field, value)
|
||||
return config
|
||||
|
||||
|
||||
def build_sdk_config(
|
||||
profile: ModelProfileSnapshot,
|
||||
space: EmbeddingSpace | None = None,
|
||||
mode: MemoryMode | None = None,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
features: Mapping[str, bool] | Iterable[tuple[str, bool]] = (),
|
||||
) -> MemoryConfig:
|
||||
"""Map a model snapshot to SDK models, endpoints, backends, and local paths."""
|
||||
"""Map a model snapshot and its embedding space to an SDK configuration.
|
||||
|
||||
`space` names the collections and the graph; the profile only says how to
|
||||
reach the models. A profile whose embedding identity (model and width) is
|
||||
not the space's raises `embedding_space_mismatch` instead of opening new,
|
||||
empty collections. Without `space`, the space the profile opened before
|
||||
the registry existed is used: offline tools on a home with no registry.
|
||||
`mode` never changes a name, and neither do `features`
|
||||
(`memory_features`), which are set on top; with none the config is
|
||||
exactly what it was before the setting existed.
|
||||
"""
|
||||
if mode is not None and mode not in {"lite", "pro", "ultra"}:
|
||||
raise HySdkRuntimeError("hy_sdk_memory_mode_invalid")
|
||||
generation_endpoint, generation_key = _generation_access(profile, api_key)
|
||||
embedding_provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
embedding_service_is_local = embedding_provider is ProviderKind.LOCAL_ONNX
|
||||
embedding_model = profile.embedding_model or (
|
||||
BUILTIN_EMBEDDING_MODEL if embedding_service_is_local else None
|
||||
)
|
||||
if not embedding_model:
|
||||
raise HySdkRuntimeError("hy_sdk_embedding_model_required")
|
||||
dimensions: int | None
|
||||
try:
|
||||
identity = embedding_identity(profile)
|
||||
except EmbeddingSpaceError as error:
|
||||
raise HySdkRuntimeError(error.code) from None
|
||||
if embedding_service_is_local:
|
||||
embedding_endpoint = None
|
||||
embedding_key = None
|
||||
dimensions = profile.embedding_dimensions or BUILTIN_EMBEDDING_DIMENSIONS
|
||||
else:
|
||||
# Never the generation endpoint: the collection name hashes this one.
|
||||
# Never the generation endpoint: a remote embedding has its own.
|
||||
embedding_endpoint = _openai_endpoint(embedding_provider, profile.embedding_base_url)
|
||||
embedding_key = "ollama" if embedding_provider is ProviderKind.OLLAMA else api_key
|
||||
if embedding_provider is ProviderKind.OPENAI_COMPATIBLE and not embedding_key:
|
||||
@@ -290,13 +390,18 @@ def build_sdk_config(
|
||||
ProviderKind.OLLAMA,
|
||||
}:
|
||||
raise HySdkRuntimeError("hy_sdk_embedding_provider_unsupported")
|
||||
dimensions = profile.embedding_dimensions
|
||||
if space is None:
|
||||
space = derived_space(profile)
|
||||
elif space.identity is None or not identity.matches(space.identity):
|
||||
raise HySdkRuntimeError("embedding_space_mismatch")
|
||||
# The width the space was opened with: its names carry it, and a remote
|
||||
# request sends it as `dimensions`. An "auto" space sends none, as before.
|
||||
dimensions = space.name_dims
|
||||
embedding_model = identity.model
|
||||
|
||||
root = data_dir.resolve() / "hy-memory" / "governed-v2"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
collection = _collection_base(
|
||||
embedding_provider, embedding_endpoint, embedding_model, dimensions
|
||||
)
|
||||
collection = space.collection_base
|
||||
|
||||
llm: dict[str, Any] = {
|
||||
"provider": "openai",
|
||||
@@ -311,7 +416,7 @@ def build_sdk_config(
|
||||
if thinking_off:
|
||||
llm["extra_body"] = thinking_off
|
||||
|
||||
return MemoryConfig.from_dict(
|
||||
config = MemoryConfig.from_dict(
|
||||
{
|
||||
"llm": llm,
|
||||
"embedder": {
|
||||
@@ -334,7 +439,7 @@ def build_sdk_config(
|
||||
},
|
||||
"graph_store": {
|
||||
"provider": "kuzu",
|
||||
"db_path": str(root / "graph"),
|
||||
"db_path": str(root / (space.graph_dir or DEFAULT_GRAPH_DIR)),
|
||||
"url": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
@@ -348,21 +453,50 @@ def build_sdk_config(
|
||||
"coding": {"db_path": str(root / "coding.sqlite3")},
|
||||
}
|
||||
)
|
||||
return apply_sdk_features(config, features)
|
||||
|
||||
|
||||
def _acquire_for_handle(
|
||||
profile: ModelProfileSnapshot,
|
||||
space: EmbeddingSpace | None,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None,
|
||||
mode: str | None,
|
||||
features: FeatureOverrides = (),
|
||||
) -> ClientLease:
|
||||
# `acquire_client` is looked up at call time, so a stand-in replaces every
|
||||
# construction. A handle without a space keeps the call it always made.
|
||||
named: dict[str, Any] = {"space": space} if space is not None else {}
|
||||
return acquire_client(
|
||||
profile,
|
||||
api_key=api_key,
|
||||
data_dir=data_dir,
|
||||
http_transport_factory=http_transport_factory,
|
||||
mode=mode,
|
||||
features=features,
|
||||
**named,
|
||||
)
|
||||
|
||||
|
||||
def acquire_client(
|
||||
profile: ModelProfileSnapshot,
|
||||
space: EmbeddingSpace | None = None,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
mode: str | None = None,
|
||||
features: FeatureOverrides = (),
|
||||
) -> ClientLease:
|
||||
"""Construct a public SDK client and return its explicit runtime lease.
|
||||
|
||||
`mode` is the SDK pipeline mode (lite / pro / ultra); None keeps the
|
||||
pre-setting default (ultra when `ONE_MEMORY_GRAPH_STORE` is set, else pro).
|
||||
Waits while `hold_stores_offline` holds this home's stores.
|
||||
`space` names the collections (`build_sdk_config`). `mode` is the SDK
|
||||
pipeline mode (lite / pro / ultra) and never changes a name; None keeps
|
||||
the pre-setting default (ultra when `ONE_MEMORY_GRAPH_STORE` is set, else
|
||||
pro). `features` are the `memory_features` set on its config; none keeps
|
||||
every SDK default. Waits while `hold_stores_offline` holds this home's stores.
|
||||
"""
|
||||
key = data_dir.resolve()
|
||||
with _STORE_GATE:
|
||||
@@ -370,12 +504,15 @@ def acquire_client(
|
||||
_STORE_GATE.wait()
|
||||
_OPENING[key] = _OPENING.get(key, 0) + 1
|
||||
try:
|
||||
named: dict[str, Any] = {"space": space} if space is not None else {}
|
||||
return _acquire_client(
|
||||
profile,
|
||||
api_key=api_key,
|
||||
data_dir=data_dir,
|
||||
http_transport_factory=http_transport_factory,
|
||||
mode=mode or effective_memory_mode(),
|
||||
features=features,
|
||||
**named,
|
||||
)
|
||||
finally:
|
||||
with _STORE_GATE:
|
||||
@@ -385,6 +522,20 @@ def acquire_client(
|
||||
_STORE_GATE.notify_all()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def first_store_open(data_dir: Path) -> Iterator[None]:
|
||||
"""One opening of this home's stores at a time: Chroma's first open is unlocked.
|
||||
|
||||
The same lock client construction takes, for anything else that opens
|
||||
Chroma beside the Runtime (the re-embed functions).
|
||||
"""
|
||||
key = data_dir.resolve()
|
||||
with _STORE_GATE:
|
||||
constructing = _CONSTRUCTING.setdefault(key, threading.Lock())
|
||||
with constructing:
|
||||
yield
|
||||
|
||||
|
||||
def hold_stores_offline(data_dir: Path) -> bool:
|
||||
"""Hold this home's SDK stores for offline maintenance; False if a client is open.
|
||||
|
||||
@@ -410,13 +561,22 @@ def release_stores(data_dir: Path) -> None:
|
||||
|
||||
def _acquire_client(
|
||||
profile: ModelProfileSnapshot,
|
||||
space: EmbeddingSpace | None = None,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None,
|
||||
mode: str = "pro",
|
||||
features: FeatureOverrides = (),
|
||||
) -> ClientLease:
|
||||
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
|
||||
config = build_sdk_config(
|
||||
profile,
|
||||
space,
|
||||
cast(MemoryMode, mode),
|
||||
api_key=api_key,
|
||||
data_dir=data_dir,
|
||||
features=features,
|
||||
)
|
||||
config.http_transport_factory = http_transport_factory
|
||||
mismatch = inspect_graph_width_mismatch(config)
|
||||
if mismatch is not None:
|
||||
@@ -472,6 +632,33 @@ def _generation_access(
|
||||
return endpoint, api_key
|
||||
|
||||
|
||||
def embedding_endpoint(profile: ModelProfileSnapshot) -> str | None:
|
||||
"""The OpenAI-compatible base URL the SDK embeds through; None when local."""
|
||||
provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
if provider is ProviderKind.LOCAL_ONNX:
|
||||
return None
|
||||
return _openai_endpoint(provider, profile.embedding_base_url)
|
||||
|
||||
|
||||
def legacy_collection_base(profile: ModelProfileSnapshot) -> str:
|
||||
"""The four-attribute name a profile's space had before the registry.
|
||||
|
||||
Existing homes are registered under exactly this name, so it must never
|
||||
change: provider, embedding endpoint, model and declared width.
|
||||
"""
|
||||
provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
local = provider is ProviderKind.LOCAL_ONNX
|
||||
model = profile.embedding_model or (BUILTIN_EMBEDDING_MODEL if local else None)
|
||||
if not model:
|
||||
raise HySdkRuntimeError("hy_sdk_embedding_model_required")
|
||||
if local:
|
||||
return _collection_base(
|
||||
provider, None, model, profile.embedding_dimensions or BUILTIN_EMBEDDING_DIMENSIONS
|
||||
)
|
||||
endpoint = _openai_endpoint(provider, profile.embedding_base_url)
|
||||
return _collection_base(provider, endpoint, model, profile.embedding_dimensions)
|
||||
|
||||
|
||||
def _collection_base(
|
||||
provider: ProviderKind,
|
||||
endpoint: str | None,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""SDK retrieval and extraction features a person may turn on or off.
|
||||
|
||||
MemHub hard-codes none of these. Each is one `MemoryConfig` field that the SDK
|
||||
reads from its config object (never from `os.environ` at call time), so
|
||||
`hy_sdk_runtime.build_sdk_config` sets it per client and a client's features
|
||||
are fixed for its life, like its mode. An unset feature keeps the SDK default,
|
||||
so with nothing set the SDK config is byte for byte what it was before this
|
||||
setting existed.
|
||||
|
||||
The setting is one global document in `config.json` (`memory_features`),
|
||||
beside `memory_mode`; management stays global, not per user. A change
|
||||
rebuilds the SDK handles the way a mode change does (#218 use count).
|
||||
|
||||
What is deliberately left out, and why, is in
|
||||
docs/contracts/memory-features-v1.md. In short: flags the SDK reads straight
|
||||
from the process environment (the System 2 schema dedup/compaction lane, the
|
||||
balanced-catalog and specific-supplement lanes, the hybrid reader's
|
||||
`MEMORY_HYBRID_V2_*` knobs) cannot be scoped to one client; flags MemHub never
|
||||
exercises (query rewrite needs `queries=[...]`, session narratives need
|
||||
`finalize_session`); flags that are dangerous or internal (graph write, local
|
||||
cross-encoder download, access write-back); and everything MemHub already
|
||||
controls elsewhere (pipeline log, trace, memory operations, telemetry, mode).
|
||||
|
||||
No SDK import here: only `hy_sdk_runtime` and `hy_sdk_migration` import it.
|
||||
`config_state` imports this module, so it imports nothing from there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictBool
|
||||
|
||||
MemoryFeatureName = Literal[
|
||||
"graph_rerank",
|
||||
"same_series_timeline",
|
||||
"episode_recall",
|
||||
"session_episodes",
|
||||
"episode_summary",
|
||||
"intention",
|
||||
"few_shot",
|
||||
]
|
||||
#: Read features change search; write features change what extraction stores.
|
||||
FeatureSide = Literal["read", "write"]
|
||||
#: none: no extra call. extra_query: one more store query per search.
|
||||
#: llm_call: extra generation-model calls. prompt_tokens: no extra call, but
|
||||
#: a longer extraction prompt.
|
||||
FeatureCost = Literal["none", "extra_query", "llm_call", "prompt_tokens"]
|
||||
#: Why an enabled feature does nothing right now (`active: false`).
|
||||
FeatureReason = Literal["disabled", "requires_pro", "requires_ultra", "requires_graph_rerank"]
|
||||
#: A client's features as a hashable key: sorted (name, value) pairs, set ones only.
|
||||
type FeatureOverrides = tuple[tuple[str, bool], ...]
|
||||
|
||||
_MODE_RANK: dict[str, int] = {"lite": 0, "pro": 1, "ultra": 2}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureSpec:
|
||||
name: MemoryFeatureName
|
||||
#: `MemoryConfig` section and field the value is written to.
|
||||
section: Literal["graph_store", "recall", "llm"]
|
||||
field: str
|
||||
#: The SDK's own env var for the same field; only its default comes from it.
|
||||
env: str
|
||||
side: FeatureSide
|
||||
#: Lowest mode the feature acts in.
|
||||
requires_mode: Literal["pro", "ultra"]
|
||||
#: Another feature that must be active for this one to act.
|
||||
requires_feature: MemoryFeatureName | None
|
||||
cost: FeatureCost
|
||||
description: str
|
||||
|
||||
@property
|
||||
def sdk_field(self) -> str:
|
||||
return f"{self.section}.{self.field}"
|
||||
|
||||
|
||||
FEATURES: tuple[FeatureSpec, ...] = (
|
||||
FeatureSpec(
|
||||
name="graph_rerank",
|
||||
section="graph_store",
|
||||
field="graph_rerank_enabled",
|
||||
env="MEMORY_GRAPH_RERANK_ENABLED",
|
||||
side="read",
|
||||
requires_mode="ultra",
|
||||
requires_feature=None,
|
||||
cost="extra_query",
|
||||
description=(
|
||||
"Usage A: propagate support over knowledge-graph edges inside the "
|
||||
"recall pool, so memories linked to strong hits rank higher. Needs "
|
||||
"the graph store (ultra). No model call; one graph query per search."
|
||||
),
|
||||
),
|
||||
FeatureSpec(
|
||||
name="same_series_timeline",
|
||||
section="recall",
|
||||
field="same_series_timeline_enabled",
|
||||
env="MEMORY_SAME_SERIES_TIMELINE_ENABLED",
|
||||
side="read",
|
||||
requires_mode="ultra",
|
||||
requires_feature="graph_rerank",
|
||||
cost="none",
|
||||
description=(
|
||||
"Keep memories joined by a same-series graph edge together, in time "
|
||||
"order, in the results. Uses the edges graph rerank reads, so it "
|
||||
"needs graph_rerank (ultra). No model call."
|
||||
),
|
||||
),
|
||||
FeatureSpec(
|
||||
name="episode_recall",
|
||||
section="recall",
|
||||
field="episode_recall_enabled",
|
||||
env="MEMORY_EPISODE_RECALL_ENABLED",
|
||||
side="read",
|
||||
requires_mode="pro",
|
||||
requires_feature=None,
|
||||
cost="extra_query",
|
||||
description=(
|
||||
"Usage B: pull in memories from the same episode as a hit and score "
|
||||
"them on tag-keyword overlap, content overlap and time affinity. No "
|
||||
"graph needed (pro or ultra). No model call; one store query per "
|
||||
"search. Helps only memories that carry an episode_id."
|
||||
),
|
||||
),
|
||||
FeatureSpec(
|
||||
name="session_episodes",
|
||||
section="recall",
|
||||
field="session_episode_enabled",
|
||||
env="MEMORY_SESSION_EPISODE_ENABLED",
|
||||
side="write",
|
||||
requires_mode="pro",
|
||||
requires_feature=None,
|
||||
cost="none",
|
||||
description=(
|
||||
"Episode write side: memories extracted from one conversation share "
|
||||
"one stable episode_id, instead of the default rule (a new episode "
|
||||
"after 30 minutes idle or 50 memories). No model call."
|
||||
),
|
||||
),
|
||||
FeatureSpec(
|
||||
name="episode_summary",
|
||||
section="recall",
|
||||
field="episode_summary_enabled",
|
||||
env="MEMORY_EPISODE_SUMMARY_ENABLED",
|
||||
side="write",
|
||||
requires_mode="pro",
|
||||
requires_feature=None,
|
||||
cost="llm_call",
|
||||
description=(
|
||||
"When an episode closes (the idle-gap rule), summarize it once into "
|
||||
"an episode memory. One model call per closed episode of 3 or more "
|
||||
"memories."
|
||||
),
|
||||
),
|
||||
FeatureSpec(
|
||||
name="intention",
|
||||
section="llm",
|
||||
field="enable_intention",
|
||||
env="MEMORY_ENABLE_INTENTION",
|
||||
side="write",
|
||||
requires_mode="pro",
|
||||
requires_feature=None,
|
||||
cost="none",
|
||||
description=(
|
||||
"Extract the user's stated plans and intentions (L7) along with "
|
||||
"facts. On by default; off drops them from the extraction prompt. "
|
||||
"No extra call."
|
||||
),
|
||||
),
|
||||
FeatureSpec(
|
||||
name="few_shot",
|
||||
section="llm",
|
||||
field="few_shot_enabled",
|
||||
env="MEMORY_FEW_SHOT_ENABLED",
|
||||
side="write",
|
||||
requires_mode="pro",
|
||||
requires_feature=None,
|
||||
cost="prompt_tokens",
|
||||
description=(
|
||||
"Append worked examples to the extraction and reconcile prompts. "
|
||||
"Can help a weaker model; no extra call, but every extraction "
|
||||
"sends more input tokens."
|
||||
),
|
||||
),
|
||||
)
|
||||
FEATURE_NAMES: tuple[MemoryFeatureName, ...] = tuple(spec.name for spec in FEATURES)
|
||||
FEATURES_BY_NAME: dict[str, FeatureSpec] = {spec.name: spec for spec in FEATURES}
|
||||
|
||||
|
||||
class MemoryFeatures(BaseModel):
|
||||
"""`config.json` `memory_features`, and the `PUT` body.
|
||||
|
||||
Every key is optional; absent (or null) means the SDK default. In a `PUT`
|
||||
an absent key is left as it is and null resets it. Unknown keys and
|
||||
non-boolean values are refused (422).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
|
||||
|
||||
graph_rerank: StrictBool | None = None
|
||||
same_series_timeline: StrictBool | None = None
|
||||
episode_recall: StrictBool | None = None
|
||||
session_episodes: StrictBool | None = None
|
||||
episode_summary: StrictBool | None = None
|
||||
intention: StrictBool | None = None
|
||||
few_shot: StrictBool | None = None
|
||||
|
||||
def overrides(self) -> FeatureOverrides:
|
||||
"""The features a person set, as the client key."""
|
||||
return feature_overrides(
|
||||
{name: value for name, value in self.model_dump().items() if value is not None}
|
||||
)
|
||||
|
||||
def merged(self, update: MemoryFeatures) -> MemoryFeatures:
|
||||
"""This document with the keys `update` names: a value sets, null resets."""
|
||||
values = self.model_dump()
|
||||
for name in update.model_fields_set:
|
||||
values[name] = getattr(update, name)
|
||||
return MemoryFeatures(**{key: value for key, value in values.items() if value is not None})
|
||||
|
||||
|
||||
def feature_overrides(values: Mapping[str, bool]) -> FeatureOverrides:
|
||||
unknown = set(values) - set(FEATURES_BY_NAME)
|
||||
if unknown:
|
||||
raise ValueError("unknown_memory_feature")
|
||||
return tuple(sorted((name, bool(value)) for name, value in values.items()))
|
||||
|
||||
|
||||
def effective_features(
|
||||
overrides: FeatureOverrides, defaults: Mapping[str, bool]
|
||||
) -> dict[str, bool]:
|
||||
"""Every feature's value: what a person set, else the SDK default."""
|
||||
values: dict[str, bool] = {spec.name: bool(defaults.get(spec.name, False)) for spec in FEATURES}
|
||||
values.update(dict(overrides))
|
||||
return values
|
||||
|
||||
|
||||
def feature_activity(name: str, effective: Mapping[str, bool], mode: str) -> FeatureReason | None:
|
||||
"""None when the feature acts in this mode; else why it does not."""
|
||||
spec = FEATURES_BY_NAME[name]
|
||||
if not effective.get(name, False):
|
||||
return "disabled"
|
||||
if _MODE_RANK.get(mode, 0) < _MODE_RANK[spec.requires_mode]:
|
||||
return "requires_ultra" if spec.requires_mode == "ultra" else "requires_pro"
|
||||
if spec.requires_feature is not None and (
|
||||
feature_activity(spec.requires_feature, effective, mode) is not None
|
||||
):
|
||||
return "requires_graph_rerank"
|
||||
return None
|
||||
|
||||
|
||||
def non_default_features(
|
||||
overrides: FeatureOverrides, defaults: Mapping[str, bool]
|
||||
) -> dict[str, bool]:
|
||||
"""The features whose value differs from the SDK default, for status."""
|
||||
return {
|
||||
name: value
|
||||
for name, value in effective_features(overrides, defaults).items()
|
||||
if value != bool(defaults.get(name, False))
|
||||
}
|
||||
@@ -17,6 +17,7 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator
|
||||
|
||||
from one_memory.memory_features import MemoryFeatures
|
||||
from one_memory.memory_queue import ExtractionWindowPreset
|
||||
|
||||
_SOURCE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,127}$")
|
||||
@@ -69,6 +70,9 @@ class ConfigDocument(BaseModel):
|
||||
#: still forces ultra over it; see `hy_graph_admin.effective_memory_mode`.
|
||||
memory_mode: MemoryMode = "pro"
|
||||
evolve_schedule: EvolveSchedule = Field(default_factory=EvolveSchedule)
|
||||
#: SDK retrieval and extraction features a person set; an unset one keeps
|
||||
#: the SDK default. See `one_memory.memory_features`.
|
||||
memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures)
|
||||
|
||||
@field_validator("sources")
|
||||
@classmethod
|
||||
@@ -91,7 +95,7 @@ class ConfigFileError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_WRITTEN_WHEN_SET = frozenset({"memory_mode", "evolve_schedule"})
|
||||
_WRITTEN_WHEN_SET = frozenset({"memory_mode", "evolve_schedule", "memory_features"})
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
@@ -166,6 +170,19 @@ class ConfigStore:
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def memory_features(self) -> MemoryFeatures:
|
||||
with self._lock:
|
||||
return self._document.memory_features
|
||||
|
||||
def set_memory_features(self, features: MemoryFeatures) -> ConfigDocument:
|
||||
if not isinstance(features, MemoryFeatures):
|
||||
raise ValueError("invalid_memory_features")
|
||||
with self._lock:
|
||||
updated = self._document.model_copy(update={"memory_features": features})
|
||||
self._write(updated)
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def migrate_extraction_window_preset(self, preset: ExtractionWindowPreset) -> ConfigDocument:
|
||||
"""Adopt the legacy SQL value only when config.json has no value."""
|
||||
if not isinstance(preset, ExtractionWindowPreset):
|
||||
@@ -214,9 +231,13 @@ class ConfigStore:
|
||||
# Settings added after 1.0.5 are written only once a person chose
|
||||
# them, so an untouched install's file stays what older builds read.
|
||||
unset = {name for name in _WRITTEN_WHEN_SET if name not in document.model_fields_set}
|
||||
values = document.model_dump(mode="json", exclude=unset)
|
||||
if "memory_features" in values:
|
||||
# Only the features a person set; an absent one is the SDK default.
|
||||
values["memory_features"] = document.memory_features.model_dump(exclude_none=True)
|
||||
payload = (
|
||||
json.dumps(
|
||||
document.model_dump(mode="json", exclude=unset),
|
||||
values,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
|
||||
@@ -253,6 +253,9 @@ MODEL_NOT_READY_CODES = frozenset(
|
||||
"model_secret_unavailable",
|
||||
"secure_storage_unavailable",
|
||||
"hy_memory_client_unavailable",
|
||||
# The active profile's embedding is not the live space's: activation
|
||||
# must go through an embedding switch, so the write waits for it.
|
||||
"embedding_space_mismatch",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2565,6 +2568,9 @@ async def execute_job(
|
||||
"model_sedimentation_disabled",
|
||||
# Evolve holds this user; the job runs once it is done.
|
||||
"memory_evolve_in_progress",
|
||||
# An embedding switch paused writes: the job stays queued and
|
||||
# sediments into the new space once the switch commits.
|
||||
"embedding_switch_in_progress",
|
||||
}
|
||||
and not hy_invoked
|
||||
):
|
||||
|
||||
@@ -1383,6 +1383,83 @@ async def _050_model_probe_operations_reserved_at(cursor: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
def embedding_spaces_ddl() -> str:
|
||||
"""The embedding space registry (`embedding_spaces.py`); the main Core SQLite only.
|
||||
|
||||
Unversioned on purpose, like the visibility tables: a migration raises
|
||||
the build's schema version, and a library this build opened would then
|
||||
refuse an older package. The table is additive and created idempotently;
|
||||
an older build ignores it and opens the space its active profile names,
|
||||
which is the registered space on every home that never switched. SQLite
|
||||
only (partial unique indexes): it lives in the main Core database.
|
||||
"""
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS embedding_spaces (
|
||||
space_id TEXT PRIMARY KEY NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('live','building','stray','retired','dropped')),
|
||||
model TEXT,
|
||||
dims INTEGER,
|
||||
name_dims INTEGER,
|
||||
collection_base TEXT NOT NULL,
|
||||
naming TEXT NOT NULL CHECK(naming IN ('legacy','v2','discovered')),
|
||||
graph_dir TEXT,
|
||||
provider_kind TEXT,
|
||||
profile_id TEXT,
|
||||
profile_revision INTEGER,
|
||||
vectors INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
live_since TEXT,
|
||||
retired_at TEXT,
|
||||
keep_until TEXT,
|
||||
dropped_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS embedding_spaces_one_live
|
||||
ON embedding_spaces(state) WHERE state = 'live';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS embedding_spaces_one_physical
|
||||
ON embedding_spaces(collection_base, COALESCE(name_dims, 0))
|
||||
WHERE state != 'dropped';
|
||||
"""
|
||||
|
||||
|
||||
def embedding_switch_jobs_ddl() -> str:
|
||||
"""Embedding switch and merge jobs (`embedding_switch.py`); unversioned, as above.
|
||||
|
||||
At most one job is open at a time (the partial unique index), and it
|
||||
lives beside the profiles and the registry so the commit is one
|
||||
transaction over all three.
|
||||
"""
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS embedding_switch_jobs (
|
||||
job_id TEXT PRIMARY KEY NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('switch','merge')),
|
||||
state TEXT NOT NULL CHECK(state IN (
|
||||
'preparing','reembedding','verifying','committing','done','failed','rolled_back'
|
||||
)),
|
||||
source_space_id TEXT NOT NULL,
|
||||
target_space_id TEXT NOT NULL,
|
||||
stray_space_ids TEXT NOT NULL DEFAULT '[]',
|
||||
target_profile_id TEXT,
|
||||
target_revision INTEGER,
|
||||
prior_profile_id TEXT,
|
||||
requested_by TEXT NOT NULL,
|
||||
plan_json TEXT NOT NULL,
|
||||
progress_json TEXT NOT NULL DEFAULT '{}',
|
||||
verification_json TEXT,
|
||||
error_code TEXT,
|
||||
error_detail TEXT,
|
||||
cancel_requested INTEGER NOT NULL DEFAULT 0,
|
||||
owner_pid INTEGER,
|
||||
heartbeat_at TEXT,
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS embedding_switch_one_open
|
||||
ON embedding_switch_jobs((1)) WHERE state NOT IN ('done','rolled_back');
|
||||
"""
|
||||
|
||||
|
||||
def model_profile_seeds_ddl(dialect: str = "mysql") -> str:
|
||||
"""Which presets have been seeded, so seeding happens once."""
|
||||
if dialect == "sqlite":
|
||||
|
||||
@@ -423,7 +423,7 @@ class ModelProber:
|
||||
ModelProbeOperations(self.store).assert_no_pending(profile.profile_id)
|
||||
# Measuring embedding width may create a new profile version of its own.
|
||||
input_revision = profile.revision
|
||||
profile = self._record_embedding_width(profile, stages)
|
||||
profile, _switch_required = self._record_embedding_width(profile, stages)
|
||||
self.store.record_capabilities(
|
||||
profile.profile_id,
|
||||
expected_revision=profile.revision,
|
||||
@@ -442,32 +442,55 @@ class ModelProber:
|
||||
self,
|
||||
profile: ModelProfile,
|
||||
stages: tuple[ProbeStage, ...],
|
||||
) -> ModelProfile:
|
||||
"""Store the measured vector width on the profile.
|
||||
) -> tuple[ModelProfile, bool]:
|
||||
"""Store the measured vector width on the profile, unless it moves the space.
|
||||
|
||||
The graph store fixes its embedding column width when it is first
|
||||
created, so guessing this leaves every relation write failing later.
|
||||
On the profile that fills the live embedding space a different width
|
||||
is not recorded: it would name other collections (B2). True when the
|
||||
width was refused that way: using it takes an embedding switch, which
|
||||
the switch plan reports (the probe result keeps its shape).
|
||||
"""
|
||||
width = next(
|
||||
(
|
||||
stage.embedding_dimensions
|
||||
for stage in stages
|
||||
if stage.stage == "embedding" and stage.embedding_dimensions
|
||||
),
|
||||
None,
|
||||
)
|
||||
width = measured_width(stages)
|
||||
if width is None or width == profile.embedding_dimensions:
|
||||
return profile
|
||||
return self.store.update(
|
||||
profile.profile_id,
|
||||
ModelProfilePatch(
|
||||
expected_revision=profile.revision,
|
||||
embedding_dimensions=width,
|
||||
return profile, False
|
||||
from one_memory.embedding_spaces import EmbeddingSpaceStore, probe_width_allowed
|
||||
|
||||
spaces = EmbeddingSpaceStore(self.store.database_path)
|
||||
with spaces.transaction() as connection:
|
||||
allowed = probe_width_allowed(
|
||||
connection,
|
||||
profile.profile_id,
|
||||
width,
|
||||
data_dir=self.store.database_path.parent,
|
||||
)
|
||||
if not allowed:
|
||||
return profile, True
|
||||
return (
|
||||
self.store.update(
|
||||
profile.profile_id,
|
||||
ModelProfilePatch(
|
||||
expected_revision=profile.revision,
|
||||
embedding_dimensions=width,
|
||||
),
|
||||
self.secrets,
|
||||
),
|
||||
self.secrets,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def measured_width(stages: tuple[ProbeStage, ...]) -> int | None:
|
||||
return next(
|
||||
(
|
||||
stage.embedding_dimensions
|
||||
for stage in stages
|
||||
if stage.stage == "embedding" and stage.embedding_dimensions
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _models_request(
|
||||
profile: ModelProfile,
|
||||
secret: str | None,
|
||||
|
||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from one_memory.database import take_the_write_lock_first
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.embedding_spaces import probe_width_allowed
|
||||
from one_memory.memory_server.migrations import model_probe_operations_ddl
|
||||
from one_memory.model_probe import ModelProbeResult, ProbeStage
|
||||
from one_memory.model_profiles import ModelCapabilities, ModelProfileStore
|
||||
@@ -349,7 +350,19 @@ class ModelProbeOperations:
|
||||
)
|
||||
revision = profile["revision"]
|
||||
now = datetime.now(UTC).isoformat()
|
||||
if width is not None and width != profile["embedding_dimensions"]:
|
||||
# The profile that fills the live embedding space records only the
|
||||
# space's own width: another one would move writes to new, empty
|
||||
# collections (B2). Using it takes an embedding switch.
|
||||
if (
|
||||
width is not None
|
||||
and width != profile["embedding_dimensions"]
|
||||
and probe_width_allowed(
|
||||
db,
|
||||
profile["profile_id"],
|
||||
width,
|
||||
data_dir=self.store.database_path.parent,
|
||||
)
|
||||
):
|
||||
revision += 1
|
||||
db.execute(
|
||||
"UPDATE model_profiles SET embedding_dimensions=?,revision=? "
|
||||
|
||||
@@ -703,10 +703,16 @@ class ModelProfileStore:
|
||||
}
|
||||
)
|
||||
# User changes to service/model/credentials need a new explicit activation.
|
||||
# Probe-discovered embedding width alone retains its existing behavior.
|
||||
# Probe-discovered embedding width alone retains its existing behavior;
|
||||
# replacing one declared width with another is a new embedding
|
||||
# identity, and activating it goes through an embedding switch.
|
||||
deactivate = (
|
||||
patch.api_key is not None
|
||||
or patch.clear_api_key
|
||||
or (
|
||||
row["embedding_dimensions"] is not None
|
||||
and next_values["embedding_dimensions"] != row["embedding_dimensions"]
|
||||
)
|
||||
or any(
|
||||
row[key] != next_values[key]
|
||||
for key in (
|
||||
|
||||
@@ -47,10 +47,13 @@ class SdkMemoryRuntime:
|
||||
extraction_settings: ExtractionSettingsStore,
|
||||
data_dir: Path,
|
||||
memory_mode: Callable[[], str] = lambda: "pro",
|
||||
writes_open: Callable[[], bool] = lambda: True,
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
#: The SDK mode, for the graph store's path (`hy_graph_admin`).
|
||||
self.memory_mode = memory_mode
|
||||
#: False while an embedding switch pauses model writes.
|
||||
self.writes_open = writes_open
|
||||
self.read_handle = read_handle
|
||||
self.write_queue = write_queue
|
||||
self.extraction_settings = extraction_settings
|
||||
@@ -111,6 +114,8 @@ class SdkMemoryRuntime:
|
||||
)
|
||||
|
||||
async def digest(self) -> dict[str, object]:
|
||||
if not self.writes_open():
|
||||
raise HySdkError("embedding_switch_in_progress")
|
||||
async with self.coordinator.use_client() as client:
|
||||
try:
|
||||
raw = await asyncio.to_thread(
|
||||
@@ -151,12 +156,14 @@ class SdkMemoryRuntime:
|
||||
if str(error) == "model_profile_not_active":
|
||||
return None
|
||||
raise
|
||||
space = await self.coordinator.live_space()
|
||||
return await asyncio.to_thread(
|
||||
inspect_profile_graph_width,
|
||||
profile,
|
||||
api_key=secret,
|
||||
data_dir=self.data_dir,
|
||||
mode=self.memory_mode(),
|
||||
graph_dir=space.graph_dir if space is not None and space.graph_dir else "graph",
|
||||
)
|
||||
|
||||
async def decide_graph_width(
|
||||
@@ -167,6 +174,7 @@ class SdkMemoryRuntime:
|
||||
profile, secret = await self.coordinator.active_profile_credentials()
|
||||
except RuntimeError as error:
|
||||
raise HySdkError(str(error)) from error
|
||||
space = await self.coordinator.live_space()
|
||||
return await asyncio.to_thread(
|
||||
decide_profile_graph_width,
|
||||
profile,
|
||||
@@ -174,6 +182,7 @@ class SdkMemoryRuntime:
|
||||
data_dir=self.data_dir,
|
||||
decision=decision,
|
||||
mode=self.memory_mode(),
|
||||
graph_dir=space.graph_dir if space is not None and space.graph_dir else "graph",
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -183,6 +192,8 @@ class SdkMemoryRuntime:
|
||||
Callers record these against the edit or deletion instead of failing
|
||||
the request that already committed it.
|
||||
"""
|
||||
if not self.writes_open():
|
||||
raise HySdkError("embedding_switch_in_progress")
|
||||
async with AsyncExitStack() as stack:
|
||||
try:
|
||||
client = await stack.enter_async_context(self.coordinator.use_client())
|
||||
|
||||
@@ -222,13 +222,34 @@ def runtime_sdk_client(
|
||||
raise DataPlaneMigrationError("sdk_migration_secure_storage_unavailable") from None
|
||||
if secret is None:
|
||||
raise DataPlaneMigrationError("sdk_migration_model_secret_unavailable")
|
||||
lease = acquire_client(profile, api_key=secret, data_dir=data_dir)
|
||||
# The registry's live space when this home has one and the profile fills
|
||||
# it; otherwise the space the profile named before the registry existed.
|
||||
# A switch in progress never changes it: the profile and the live space
|
||||
# change together, at the switch's commit.
|
||||
space = live_space_for(database_path, profile)
|
||||
lease = (
|
||||
acquire_client(profile, space, api_key=secret, data_dir=data_dir)
|
||||
if space is not None
|
||||
else acquire_client(profile, api_key=secret, data_dir=data_dir)
|
||||
)
|
||||
try:
|
||||
yield lease.client
|
||||
finally:
|
||||
lease.close()
|
||||
|
||||
|
||||
def live_space_for(database_path: Path, profile: ModelProfileSnapshot) -> Any:
|
||||
"""The live embedding space `profile` reads, or None to derive it from the profile."""
|
||||
from one_memory.embedding_spaces import embedding_identity, read_live_space
|
||||
|
||||
space = read_live_space(database_path)
|
||||
if space is None or space.identity is None:
|
||||
return None
|
||||
if not embedding_identity(profile).matches(space.identity):
|
||||
return None
|
||||
return space
|
||||
|
||||
|
||||
def verify_readback(
|
||||
client: Any, legacy: LegacyMemoryIds, *, chat_missing_before_migration: int = 0
|
||||
) -> ReadbackReport:
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"""The read client, kept in the embedding space the active profile writes to.
|
||||
"""The read client, kept in the live embedding space.
|
||||
|
||||
Every read used to open the built-in 512-dim collection while writes followed
|
||||
the active profile, so a custom embedding read an empty space (#242). This
|
||||
handle opens the active profile's `retrieval_view` instead, and the built-in
|
||||
space only when no profile is active.
|
||||
the active profile, so a custom embedding read an empty space (#242). Reads
|
||||
now open the space the registry pins as live (`embedding_spaces`), which is
|
||||
the space writes fill. The embedding that reaches it is the active profile's
|
||||
when its model and width are the space's -- a new endpoint for the same model
|
||||
keeps the space -- and otherwise the profile version that filled the space
|
||||
(the built-in model for the built-in space). A switch that is still running
|
||||
never changes this: reads keep serving the old space until its commit.
|
||||
|
||||
A profile change builds a new client and retires the old one; a retired client
|
||||
is closed once no task still uses it, as `SdkWriteCoordinator` does for writes
|
||||
(#218). A use is the task that asked for the client: callers keep their
|
||||
`client = await handle.client()` shape, and the use ends with that task (one
|
||||
HTTP request, one retention pass).
|
||||
A change of space or profile builds a new client and retires the old one; a
|
||||
retired client is closed once no task still uses it, as `SdkWriteCoordinator`
|
||||
does for writes (#218). A use is the task that asked for the client: callers
|
||||
keep their `client = await handle.client()` shape, and the use ends with that
|
||||
task (one HTTP request, one retention pass).
|
||||
|
||||
The memory mode (lite / pro / ultra) is fixed per SDK client, so a mode change
|
||||
rebuilds and retires the same way: handles are keyed by (profile, mode). The
|
||||
mode never names a collection; it is a separate client parameter.
|
||||
rebuilds and retires the same way, and so do the SDK features
|
||||
(`memory_features`): handles are keyed by (space, profile, mode, features).
|
||||
Neither mode nor features names a collection; both are separate client
|
||||
parameters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,22 +29,33 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from one_memory.embedding_health import EmbeddingGuardedClient, EmbeddingHealth
|
||||
from one_memory.embedding_spaces import (
|
||||
EmbeddingSpace,
|
||||
EmbeddingSpaceStore,
|
||||
embedding_identity,
|
||||
registry_for,
|
||||
)
|
||||
from one_memory.hy_graph_admin import effective_memory_mode
|
||||
from one_memory.hy_sdk_runtime import (
|
||||
BUILTIN_RETRIEVAL_PROFILE_ID,
|
||||
SdkClientHandle,
|
||||
builtin_retrieval_snapshot,
|
||||
embedding_endpoint,
|
||||
retrieval_view,
|
||||
)
|
||||
from one_memory.hy_sdk_types import HySdkRuntimeError
|
||||
from one_memory.model_profiles import ModelProfileStore, ProviderKind
|
||||
from one_memory.memory_features import FeatureOverrides
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, ProviderKind
|
||||
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
||||
|
||||
_BUILTIN = (BUILTIN_RETRIEVAL_PROFILE_ID, 0)
|
||||
#: (space id, profile id, revision, memory mode, features)
|
||||
_HandleKey = tuple[str, str, int, str, FeatureOverrides]
|
||||
|
||||
|
||||
class ActiveProfileReadHandle(SdkClientHandle):
|
||||
"""An `SdkClientHandle` whose profile follows the active one."""
|
||||
"""An `SdkClientHandle` that follows the live space and its embedding."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -47,13 +64,22 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
secrets: SecretStore,
|
||||
data_dir: Path,
|
||||
memory_mode: Callable[[], str] = effective_memory_mode,
|
||||
features: Callable[[], FeatureOverrides] = lambda: (),
|
||||
spaces: EmbeddingSpaceStore | None = None,
|
||||
health: EmbeddingHealth | None = None,
|
||||
) -> None:
|
||||
super().__init__(builtin_retrieval_snapshot(), api_key=None, data_dir=data_dir)
|
||||
self.profiles = profiles
|
||||
self.secrets = secrets
|
||||
self.data_dir = data_dir
|
||||
self.memory_mode = memory_mode
|
||||
self._handles: dict[tuple[str, int, str], SdkClientHandle] = {}
|
||||
self.current_features = features
|
||||
#: None only for a profile store without a database (test doubles).
|
||||
self.spaces = spaces if spaces is not None else registry_for(profiles)
|
||||
self.health = health or EmbeddingHealth()
|
||||
self._handles: dict[_HandleKey, SdkClientHandle] = {}
|
||||
#: Whether an active profile version reads a space, per (profile, space).
|
||||
self._matches: dict[tuple[str, int, str], bool] = {}
|
||||
#: Tasks in flight per handle; a retired handle closes at zero.
|
||||
self._uses: dict[SdkClientHandle, int] = {}
|
||||
self._retired: set[SdkClientHandle] = set()
|
||||
@@ -61,22 +87,45 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
self._handle_lock = asyncio.Lock()
|
||||
|
||||
async def client(self) -> Any:
|
||||
profile = await asyncio.to_thread(self.profiles.active_identity) or _BUILTIN
|
||||
identity = (*profile, self.memory_mode())
|
||||
handle = await self.current_handle()
|
||||
return self._guarded(handle, await handle.client())
|
||||
|
||||
def _guarded(self, handle: SdkClientHandle, client: Any) -> Any:
|
||||
"""A remote embedding's searches report `embedding_unavailable` (no fallback)."""
|
||||
profile = getattr(handle, "profile", None)
|
||||
if not isinstance(profile, ModelProfileSnapshot):
|
||||
return client
|
||||
endpoint = embedding_endpoint(profile)
|
||||
if endpoint is None:
|
||||
return client
|
||||
space = handle.space
|
||||
name_dims = space.name_dims if space is not None else profile.embedding_dimensions
|
||||
return EmbeddingGuardedClient(
|
||||
client,
|
||||
lambda: self.health.available(
|
||||
profile, endpoint=endpoint, name_dims=name_dims, api_key=handle.api_key
|
||||
),
|
||||
)
|
||||
|
||||
async def current_handle(self) -> SdkClientHandle:
|
||||
"""The handle of the live space, held for the asking task."""
|
||||
space, identity = await asyncio.to_thread(self._resolve)
|
||||
mode, features = self.memory_mode(), self.current_features()
|
||||
key = (space.space_id if space is not None else "", *identity, mode, features)
|
||||
async with self._handle_lock:
|
||||
handle = self._handles.get(identity)
|
||||
handle = self._handles.get(key)
|
||||
if handle is None:
|
||||
# Only on a change of profile: resolving a key can reach the
|
||||
# system keychain, so it is not done on every read.
|
||||
handle = await asyncio.to_thread(self._build, identity)
|
||||
self._handles[identity] = handle
|
||||
self._retired.update(value for key, value in self._handles.items() if key != identity)
|
||||
self._handles = {identity: handle}
|
||||
# Only on a change of space or profile: resolving a key can
|
||||
# reach the system keychain, so it is not done on every read.
|
||||
handle = await asyncio.to_thread(self._build, space, identity, mode, features)
|
||||
self._handles[key] = handle
|
||||
self._retired.update(value for name, value in self._handles.items() if name != key)
|
||||
self._handles = {key: handle}
|
||||
unused = {value for value in self._retired if not self._uses.get(value)}
|
||||
self._retired -= unused
|
||||
self._hold(handle)
|
||||
await asyncio.gather(*(value.close() for value in unused))
|
||||
return await handle.client()
|
||||
return handle
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self._handle_lock:
|
||||
@@ -84,6 +133,51 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
self._handles, self._retired, self._uses = {}, set(), {}
|
||||
await asyncio.gather(*(handle.close() for handle in handles), *self._closing)
|
||||
|
||||
async def close_idle(self) -> None:
|
||||
"""Retire every client; the unused close now, the rest when their task ends."""
|
||||
async with self._handle_lock:
|
||||
self._retired.update(self._handles.values())
|
||||
self._handles = {}
|
||||
unused = {value for value in self._retired if not self._uses.get(value)}
|
||||
self._retired -= unused
|
||||
await asyncio.gather(*(value.close() for value in unused))
|
||||
|
||||
def _resolve(self) -> tuple[EmbeddingSpace | None, tuple[str, int]]:
|
||||
active = self.profiles.active_identity()
|
||||
if self.spaces is None:
|
||||
return None, active or _BUILTIN
|
||||
space = self.spaces.live()
|
||||
if space is None:
|
||||
# A home this process has not bootstrapped yet: register what it
|
||||
# already uses, under the name it already has.
|
||||
space = self.spaces.bootstrap(
|
||||
self.profiles.snapshot(*active) if active is not None else None
|
||||
)
|
||||
if active is not None and self._reads(active, space):
|
||||
return space, active
|
||||
if space.profile_id is not None and space.profile_revision is not None:
|
||||
identity = (space.profile_id, space.profile_revision)
|
||||
try:
|
||||
self.profiles.snapshot(*identity)
|
||||
except KeyError:
|
||||
# Its endpoint and key went with the profile; the built-in
|
||||
# model cannot read these vectors. Say so, never read wrong.
|
||||
raise HySdkRuntimeError("embedding_space_profile_missing") from None
|
||||
return space, identity
|
||||
return space, _BUILTIN
|
||||
|
||||
def _reads(self, identity: tuple[str, int], space: EmbeddingSpace) -> bool:
|
||||
key = (*identity, space.space_id)
|
||||
known = self._matches.get(key)
|
||||
if known is None:
|
||||
if space.identity is None:
|
||||
known = False
|
||||
else:
|
||||
profile = self.profiles.snapshot(*identity)
|
||||
known = embedding_identity(profile).matches(space.identity)
|
||||
self._matches[key] = known
|
||||
return known
|
||||
|
||||
def _hold(self, handle: SdkClientHandle) -> None:
|
||||
"""Count a use of `handle` until the asking task finishes."""
|
||||
task = asyncio.current_task()
|
||||
@@ -104,14 +198,26 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
self._closing.add(closing)
|
||||
closing.add_done_callback(self._closing.discard)
|
||||
|
||||
def _build(self, key: tuple[str, int, str]) -> SdkClientHandle:
|
||||
profile_id, revision, mode = key
|
||||
identity = (profile_id, revision)
|
||||
def _build(
|
||||
self,
|
||||
space: EmbeddingSpace | None,
|
||||
identity: tuple[str, int],
|
||||
mode: str,
|
||||
features: FeatureOverrides = (),
|
||||
) -> SdkClientHandle:
|
||||
# Without a registry (a profile store with no database) the handle
|
||||
# opens the space its profile names, exactly as before.
|
||||
named: dict[str, Any] = {"space": space} if space is not None else {}
|
||||
if identity == _BUILTIN:
|
||||
return SdkClientHandle(
|
||||
builtin_retrieval_snapshot(), api_key=None, data_dir=self.data_dir, mode=mode
|
||||
builtin_retrieval_snapshot(),
|
||||
api_key=None,
|
||||
data_dir=self.data_dir,
|
||||
mode=mode,
|
||||
features=features,
|
||||
**named,
|
||||
)
|
||||
profile = self.profiles.snapshot(*identity)
|
||||
profile: ModelProfileSnapshot = self.profiles.snapshot(*identity)
|
||||
view = retrieval_view(profile)
|
||||
secret: str | None = None
|
||||
# Only a remote OpenAI-compatible embedding sends the shared key.
|
||||
@@ -122,4 +228,6 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
raise HySdkRuntimeError("secure_storage_unavailable") from None
|
||||
if secret is None:
|
||||
raise HySdkRuntimeError("model_secret_unavailable")
|
||||
return SdkClientHandle(view, api_key=secret, data_dir=self.data_dir, mode=mode)
|
||||
return SdkClientHandle(
|
||||
view, api_key=secret, data_dir=self.data_dir, mode=mode, features=features, **named
|
||||
)
|
||||
@@ -73,6 +73,30 @@ def _stored_names(data_dir: Path, *, filled: bool = False) -> tuple[str, ...]:
|
||||
return tuple(sorted(str(row[0]) for row in rows))
|
||||
|
||||
|
||||
def filled_collection_names(data_dir: Path) -> tuple[str, ...]:
|
||||
"""Every vector collection holding at least one record. Read-only.
|
||||
|
||||
Safe beside a serving Core: it reads Chroma's own SQLite file, never the
|
||||
client system the SDK shares.
|
||||
"""
|
||||
return _stored_names(data_dir, filled=True)
|
||||
|
||||
|
||||
def stored_collection_width(data_dir: Path, name: str) -> int | None:
|
||||
"""The vector width Chroma fixed for `name` on its first add; None if unset."""
|
||||
database = _vector_directory(data_dir) / "chroma.sqlite3"
|
||||
if not database.is_file():
|
||||
return None
|
||||
with closing(sqlite3.connect(f"file:{database}?mode=ro", uri=True)) as connection:
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT dimension FROM collections WHERE name=?", (name,)
|
||||
).fetchone()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
return int(row[0]) if row is not None and row[0] is not None else None
|
||||
|
||||
|
||||
def legacy_vector_collections(
|
||||
data_dir: Path, profile: ModelProfileSnapshot
|
||||
) -> tuple[tuple[str, str], ...]:
|
||||
|
||||
@@ -9,10 +9,17 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from one_memory.embedding_spaces import (
|
||||
EmbeddingSpace,
|
||||
EmbeddingSpaceStore,
|
||||
embedding_identity,
|
||||
registry_for,
|
||||
)
|
||||
from one_memory.hy_graph_admin import effective_memory_mode
|
||||
from one_memory.hy_sdk_runtime import SdkClientHandle, builtin_retrieval_snapshot
|
||||
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
|
||||
from one_memory.input_sanitization import sanitize_model_input
|
||||
from one_memory.memory_features import FeatureOverrides
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
|
||||
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
||||
|
||||
@@ -146,14 +153,21 @@ class SdkWriteCoordinator:
|
||||
the SharedRuntime closed mid-`add`, the job failed with AttributeError
|
||||
after HY was invoked, and it was left `unknown`.
|
||||
|
||||
Handles are keyed by profile and memory mode, so a mode change retires
|
||||
handles the same way. In lite no generation model is needed: without an
|
||||
Handles are keyed by profile, memory mode and the SDK features
|
||||
(`memory_features`), so a mode or feature change retires handles the same
|
||||
way. In lite no generation model is needed: without an
|
||||
active profile the built-in local embedder writes raw turns.
|
||||
|
||||
`exclusive_for_evolve(user_id)` keeps evolve and that user's writes
|
||||
apart: it waits for the user's write in flight, and while it runs that
|
||||
user's jobs are refused before the SDK is called
|
||||
(`memory_evolve_in_progress`), so the drain puts them back.
|
||||
|
||||
Writes go to the live embedding space only. An active profile whose
|
||||
embedding (model and width) is not the live space's is refused with
|
||||
`embedding_space_mismatch`: it used to open a new, empty collection.
|
||||
While an embedding switch runs, `writes_open` is false and no write
|
||||
starts (`embedding_switch_in_progress`); the job waits in the queue.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -165,6 +179,9 @@ class SdkWriteCoordinator:
|
||||
transport_factory: Callable[[ModelProfileSnapshot], Callable[[], Any] | None],
|
||||
sedimentation_enabled: Callable[[], bool],
|
||||
memory_mode: Callable[[], str] = effective_memory_mode,
|
||||
memory_features: Callable[[], FeatureOverrides] = lambda: (),
|
||||
spaces: EmbeddingSpaceStore | None = None,
|
||||
writes_open: Callable[[], bool] = lambda: True,
|
||||
) -> None:
|
||||
self.profiles = profiles
|
||||
self.secrets = secrets
|
||||
@@ -172,7 +189,9 @@ class SdkWriteCoordinator:
|
||||
self.transport_factory = transport_factory
|
||||
self.sedimentation_enabled = sedimentation_enabled
|
||||
self.memory_mode = memory_mode
|
||||
self._handles: dict[tuple[str, int, str], SdkClientHandle] = {}
|
||||
self.memory_features = memory_features
|
||||
#: Keyed by (profile id, revision, space id, memory mode, features).
|
||||
self._handles: dict[tuple[str, int, str, str, FeatureOverrides], SdkClientHandle] = {}
|
||||
#: Users evolve (or the graph backfill) is running for, the writes in
|
||||
#: flight per user, and one exclusive run per user at a time. Writes
|
||||
#: of one user still run side by side; only evolve waits for them.
|
||||
@@ -180,6 +199,10 @@ class SdkWriteCoordinator:
|
||||
self._writing: dict[str, int] = {}
|
||||
self._writes_idle = asyncio.Condition()
|
||||
self._exclusive: dict[str, asyncio.Lock] = {}
|
||||
#: None only for a profile store without a database (test doubles):
|
||||
#: then every client opens the space its profile names, as before.
|
||||
self.spaces = spaces if spaces is not None else registry_for(profiles)
|
||||
self.writes_open = writes_open
|
||||
#: Uses in flight per handle; a retired handle closes at zero.
|
||||
self._uses: dict[SdkClientHandle, int] = {}
|
||||
self._retired: set[SdkClientHandle] = set()
|
||||
@@ -194,6 +217,9 @@ class SdkWriteCoordinator:
|
||||
) -> SdkWriteReceipt:
|
||||
if not self.sedimentation_enabled():
|
||||
raise RuntimeError("model_sedimentation_disabled")
|
||||
if not self.writes_open():
|
||||
# An embedding switch paused writes: the drain puts the job back.
|
||||
raise RuntimeError("embedding_switch_in_progress")
|
||||
user_id = records[0].user_id if records else "local"
|
||||
if user_id in self._evolving:
|
||||
# Before any SDK call: the drain returns the job to pending.
|
||||
@@ -217,15 +243,16 @@ class SdkWriteCoordinator:
|
||||
async with self._writes_idle:
|
||||
self._writes_idle.notify_all()
|
||||
|
||||
async def retire_stale_modes(self) -> None:
|
||||
"""After a mode change: retire handles of other modes, closing the idle ones.
|
||||
async def retire_stale_handles(self) -> None:
|
||||
"""After a mode or feature change: retire handles built otherwise.
|
||||
|
||||
A handle still writing is closed when its last use ends (#218), so a
|
||||
write that started in the old mode finishes in it.
|
||||
The idle ones close now. A handle still writing is closed when its
|
||||
last use ends (#218), so a write that started in the old mode, or
|
||||
with the old features, finishes in it.
|
||||
"""
|
||||
mode = self.memory_mode()
|
||||
build = (self.memory_mode(), self.memory_features())
|
||||
async with self._lock:
|
||||
stale = {key: handle for key, handle in self._handles.items() if key[2] != mode}
|
||||
stale = {key: handle for key, handle in self._handles.items() if key[3:] != build}
|
||||
for key in stale:
|
||||
del self._handles[key]
|
||||
self._retired.update(stale.values())
|
||||
@@ -233,6 +260,9 @@ class SdkWriteCoordinator:
|
||||
self._retired -= unused
|
||||
await asyncio.gather(*(value.close() for value in unused))
|
||||
|
||||
#: The name #253 introduced; a mode change is one kind of rebuild.
|
||||
retire_stale_modes = retire_stale_handles
|
||||
|
||||
def evolving(self, user_id: str) -> bool:
|
||||
"""Whether evolve holds this user, so their queued writes should wait."""
|
||||
return user_id in self._evolving
|
||||
@@ -242,8 +272,11 @@ class SdkWriteCoordinator:
|
||||
"""Hold one user's writes off while evolve runs for them.
|
||||
|
||||
New writes of the user are refused from here on; the ones already in
|
||||
flight are waited for before the body runs.
|
||||
flight are waited for before the body runs. Evolve and the graph
|
||||
backfill write too, so an embedding switch refuses them as well.
|
||||
"""
|
||||
if not self.writes_open():
|
||||
raise RuntimeError("embedding_switch_in_progress")
|
||||
async with self._exclusive.setdefault(user_id, asyncio.Lock()):
|
||||
self._evolving.add(user_id)
|
||||
try:
|
||||
@@ -263,6 +296,19 @@ class SdkWriteCoordinator:
|
||||
self._handles, self._retired, self._uses = {}, set(), {}
|
||||
await asyncio.gather(*(handle.close() for handle in handles))
|
||||
|
||||
def in_use(self) -> int:
|
||||
"""Writes and reads holding a client right now."""
|
||||
return sum(self._uses.values())
|
||||
|
||||
async def close_idle(self) -> None:
|
||||
"""Retire every handle; the unused close now, the rest when released."""
|
||||
async with self._lock:
|
||||
self._retired.update(self._handles.values())
|
||||
self._handles = {}
|
||||
unused = {value for value in self._retired if not self._uses.get(value)}
|
||||
self._retired -= unused
|
||||
await asyncio.gather(*(value.close() for value in unused))
|
||||
|
||||
@asynccontextmanager
|
||||
async def use_client(self) -> AsyncIterator[Any]:
|
||||
"""The public SDK client of the active write profile, kept open while used."""
|
||||
@@ -273,19 +319,25 @@ class SdkWriteCoordinator:
|
||||
self,
|
||||
) -> tuple[ModelProfileSnapshot, str | None]:
|
||||
"""Resolve one stable profile snapshot for graph administration."""
|
||||
_identity, profile, secret = await asyncio.to_thread(self._read_active_credentials)
|
||||
_identity, profile, secret, _space = await asyncio.to_thread(self._read_active_credentials)
|
||||
return profile, secret
|
||||
|
||||
async def live_space(self) -> EmbeddingSpace | None:
|
||||
if self.spaces is None:
|
||||
return None
|
||||
return await asyncio.to_thread(self._live_space)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _active_handle(self) -> AsyncIterator[SdkClientHandle]:
|
||||
"""Use the active profile's handle; retire the others, closing the unused."""
|
||||
mode = self.memory_mode()
|
||||
profile_identity, profile, secret = await asyncio.to_thread(
|
||||
features = self.memory_features()
|
||||
profile_identity, profile, secret, space = await asyncio.to_thread(
|
||||
self._read_active_credentials, mode
|
||||
)
|
||||
identity = (*profile_identity, mode)
|
||||
key = (*profile_identity, space.space_id if space is not None else "", mode, features)
|
||||
async with self._lock:
|
||||
handle = self._handles.get(identity)
|
||||
handle = self._handles.get(key)
|
||||
if handle is None:
|
||||
handle = SdkClientHandle(
|
||||
profile,
|
||||
@@ -293,10 +345,12 @@ class SdkWriteCoordinator:
|
||||
data_dir=self.data_dir,
|
||||
http_transport_factory=self.transport_factory(profile),
|
||||
mode=mode,
|
||||
features=features,
|
||||
space=space,
|
||||
)
|
||||
self._handles[identity] = handle
|
||||
self._retired.update(value for key, value in self._handles.items() if key != identity)
|
||||
self._handles = {identity: handle}
|
||||
self._handles[key] = handle
|
||||
self._retired.update(value for name, value in self._handles.items() if name != key)
|
||||
self._handles = {key: handle}
|
||||
self._uses[handle] = self._uses.get(handle, 0) + 1
|
||||
unused = {value for value in self._retired if not self._uses.get(value)}
|
||||
self._retired -= unused
|
||||
@@ -315,18 +369,51 @@ class SdkWriteCoordinator:
|
||||
self._retired.discard(handle)
|
||||
await handle.close()
|
||||
|
||||
def _live_space(self) -> EmbeddingSpace:
|
||||
assert self.spaces is not None
|
||||
space = self.spaces.live()
|
||||
if space is None:
|
||||
active = self.profiles.active_identity()
|
||||
space = self.spaces.bootstrap(
|
||||
self.profiles.snapshot(*active) if active is not None else None
|
||||
)
|
||||
return space
|
||||
|
||||
def _lite_credentials(
|
||||
self,
|
||||
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]:
|
||||
space = self._live_space() if self.spaces is not None else None
|
||||
if space is None or space.profile_id is None or space.profile_revision is None:
|
||||
builtin = builtin_retrieval_snapshot()
|
||||
return (builtin.profile_id, builtin.revision), builtin, None, space
|
||||
identity = (space.profile_id, space.profile_revision)
|
||||
profile = self.profiles.snapshot(*identity)
|
||||
try:
|
||||
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
|
||||
except SecretStoreUnavailableError:
|
||||
raise RuntimeError("secure_storage_unavailable") from None
|
||||
if profile.has_api_key and secret is None:
|
||||
raise RuntimeError("model_secret_unavailable")
|
||||
return identity, profile, secret, space
|
||||
|
||||
def _read_active_credentials(
|
||||
self,
|
||||
mode: str = "pro",
|
||||
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None]:
|
||||
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]:
|
||||
identity = self.profiles.active_identity()
|
||||
if identity is None:
|
||||
if mode == "lite":
|
||||
# lite stores raw turns with one embedding and calls no model.
|
||||
builtin = builtin_retrieval_snapshot()
|
||||
return (builtin.profile_id, builtin.revision), builtin, None
|
||||
# lite stores raw turns with one embedding and calls no model:
|
||||
# the live space's own, so they land where reads look.
|
||||
return self._lite_credentials()
|
||||
raise RuntimeError("model_profile_not_active")
|
||||
profile = self.profiles.snapshot(*identity)
|
||||
space = self._live_space() if self.spaces is not None else None
|
||||
if space is not None and (
|
||||
space.identity is None or not embedding_identity(profile).matches(space.identity)
|
||||
):
|
||||
# Never a new, empty collection: the switch moves the space.
|
||||
raise RuntimeError("embedding_space_mismatch")
|
||||
try:
|
||||
secret = self.profiles.resolve_snapshot_secret(
|
||||
*identity,
|
||||
@@ -338,7 +425,7 @@ class SdkWriteCoordinator:
|
||||
raise RuntimeError("model_profile_changed")
|
||||
if profile.has_api_key and secret is None:
|
||||
raise RuntimeError("model_secret_unavailable")
|
||||
return identity, profile, secret
|
||||
return identity, profile, secret, space
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -145,6 +145,10 @@ async def search_within(
|
||||
return (), "failed"
|
||||
error = pending.exception()
|
||||
if error is not None:
|
||||
if getattr(error, "code", None) == "embedding_unavailable":
|
||||
# Not "HY found nothing": the query could not be embedded, and
|
||||
# the caller must say so instead of answering empty.
|
||||
raise error
|
||||
return (), "failed"
|
||||
found = pending.result()
|
||||
normalized = normalize_semantic_hits(found)
|
||||
|
||||
@@ -88,6 +88,9 @@
|
||||
| `test_diagnostic_trace_api.py` | `integration` | 覆盖 trace lookup is disabled until detailed logging is enabled、trace lookup returns only safe structured steps、log level route applies current process level 等 4 个场景。 |
|
||||
| `test_disable_thinking.py` | `contract` | 运行期生成请求须按档案可选关闭思考;默认不向厂商下发未知字段。 |
|
||||
| `test_dual_engine_helper.py` | `contract` | Tests for the dual-engine harness itself. |
|
||||
| `test_embedding_switch.py` | `contract` | Switching the embedding model, and merging stray spaces, without losing a memory. |
|
||||
| `test_embedding_switch_api.py` | `integration` | The embedding switch through Core's HTTP surface and lifespan. |
|
||||
| `test_embedding_switch_sdk_adapter.py` | `contract` | The re-embed adapter against the SDK's real re-embed module and Chroma. |
|
||||
| `test_event_loop_blocking.py` | `contract` | Proof, as a test, that blocking work on the loop freezes it — and the fix works. |
|
||||
| `test_evidence_store.py` | `contract` | 覆盖 same source message merges origins without duplication、identical text from different sources is not deduplicated、rescanning an unchanged file does not write the message again 等 26 个场景。 |
|
||||
| `test_export_probe.py` | `contract` | What a probe may say about a stranger's chat history, and what it may not. |
|
||||
@@ -159,6 +162,7 @@
|
||||
| `test_memory_citation_query_plan.py` | `contract` | Correlated citation checks must look links up by memory, not scan by 'active'. |
|
||||
| `test_memory_content_import.py` | `integration` | 覆盖 existing memory preview keeps unknown author and exact original、invalid existing memory records never leave a partial upload、memory content requires explicit mode and rejects vendor guessing 等 5 个场景。 |
|
||||
| `test_memory_evidence_freshness.py` | `contract` | 新鲜度拆除后:读路径恒为 current;证据修订 / 畸形 proof / segment ref 不再藏记忆或 503。 |
|
||||
| `test_memory_features.py` | `contract` | The SDK retrieval and extraction features MemHub lets a person turn on. |
|
||||
| `test_memory_freshness_integration_review.py` | `integration` | Independent synthetic read-path review; no lifespan, model or user database. |
|
||||
| `test_memory_library_all_scenes.py` | `integration` | The v2 memory library across chat, working and coding memories. |
|
||||
| `test_memory_mode_and_evolve.py` | `contract` | The memory mode (lite / pro / ultra), evolve and its nightly schedule. |
|
||||
@@ -313,4 +317,4 @@
|
||||
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
||||
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
||||
|
||||
共 308 个测试文件。
|
||||
共 312 个测试文件。
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 308
|
||||
EXPECTED_TEST_FILES_SHA256 = "3aadf5953e41c01d4b60469f47242c00d2acef3c42550aa017255e31d216b589"
|
||||
EXPECTED_TEST_FILE_COUNT = 312
|
||||
EXPECTED_TEST_FILES_SHA256 = "7d0dc6efd88d71d5275bea8016a0ff087ff7568abcb8fd01e5f82c77a08a484f"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
@@ -54,6 +54,7 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_backup_endpoint.py",
|
||||
"test_recall_raw_fallback.py",
|
||||
"test_core_ready_before_recovery.py",
|
||||
"test_embedding_switch_api.py",
|
||||
"test_backup_restore_api.py",
|
||||
"test_c1_import_via_api_path.py",
|
||||
"test_capture_deadline.py",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""An in-memory `ReembedBackend` for embedding switch tests.
|
||||
|
||||
It keeps collections as plain dicts named exactly as the SDK names them, so a
|
||||
test can lay out a home -- a live space, a stray 512 space left by an older
|
||||
build, a retired space -- and check what a switch or merge did to each. The
|
||||
vectors themselves are not computed: a record remembers the model and width
|
||||
it was "embedded" with, which is what the assertions need.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from one_memory.embedding_backend import (
|
||||
EmbeddingTarget,
|
||||
EmbeddingWidthError,
|
||||
GraphCheck,
|
||||
PhysicalSpace,
|
||||
ReembedCancelledError,
|
||||
ReembedStep,
|
||||
ScannedSpace,
|
||||
VectorCheck,
|
||||
)
|
||||
from one_memory.embedding_spaces import VECTOR_ROLES, space_collection_names
|
||||
|
||||
_ROLE_SUFFIX = re.compile(
|
||||
r"^(?P<base>.+?)_(?P<role>coding_keys_cv|coding_keys|working_facts_cv|working_facts)"
|
||||
r"(?:_(?P<dims>\d+))?$"
|
||||
)
|
||||
_DIMS_SUFFIX = re.compile(r"^(?P<base>.+)_(?P<dims>\d+)$")
|
||||
|
||||
|
||||
class Crash(BaseException):
|
||||
"""Stands in for the process dying: nothing in the worker catches it."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Record:
|
||||
document: str
|
||||
metadata: dict[str, object]
|
||||
width: int
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeVectorStore:
|
||||
collections: dict[str, dict[str, Record]] = field(default_factory=dict)
|
||||
|
||||
def put(
|
||||
self,
|
||||
base: str,
|
||||
name_dims: int | None,
|
||||
records: dict[str, tuple[str, str]],
|
||||
*,
|
||||
role: str = "memory",
|
||||
width: int | None = None,
|
||||
model: str = "old-model",
|
||||
) -> None:
|
||||
"""`records` maps id -> (document, user_id)."""
|
||||
name = space_collection_names(base, name_dims)[role]
|
||||
target = self.collections.setdefault(name, {})
|
||||
for record_id, (document, user) in records.items():
|
||||
target[record_id] = Record(
|
||||
document=document,
|
||||
metadata={"user_id": user, "content": document},
|
||||
width=width or name_dims or 0,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def space(self, base: str, name_dims: int | None) -> dict[str, dict[str, Record]]:
|
||||
return {
|
||||
role: dict(self.collections.get(name, {}))
|
||||
for role, name in space_collection_names(base, name_dims).items()
|
||||
if name in self.collections
|
||||
}
|
||||
|
||||
def ids(self, base: str, name_dims: int | None, role: str = "memory") -> set[str]:
|
||||
return set(self.collections.get(space_collection_names(base, name_dims)[role], {}))
|
||||
|
||||
|
||||
def parse(name: str) -> tuple[str, int | None, str] | None:
|
||||
if name.endswith("_tag_index"):
|
||||
return None
|
||||
match = _ROLE_SUFFIX.match(name)
|
||||
if match:
|
||||
dims = match.group("dims")
|
||||
return match.group("base"), int(dims) if dims else None, match.group("role")
|
||||
role = "memory"
|
||||
if name.endswith("_entities"):
|
||||
name, role = name[: -len("_entities")], "entities"
|
||||
match = _DIMS_SUFFIX.match(name)
|
||||
if match:
|
||||
return match.group("base"), int(match.group("dims")), role
|
||||
return name, None, role
|
||||
|
||||
|
||||
class FakeReembedBackend:
|
||||
"""The backend contract, over `FakeVectorStore`."""
|
||||
|
||||
def __init__(self, store: FakeVectorStore | None = None) -> None:
|
||||
self.store = store or FakeVectorStore()
|
||||
self.unavailable: str | None = None
|
||||
#: Called before every batch; a test raises `Crash` or flips a cancel here.
|
||||
self.before_batch: Callable[[str, int], None] = lambda _role, _done: None
|
||||
#: Width the "embedding service" returns; None returns the target's.
|
||||
self.returned_width: int | None = None
|
||||
#: Ids the re-embed silently loses, to prove verification catches it.
|
||||
self.lose_ids: set[str] = set()
|
||||
self.batch_size = 2
|
||||
self.reembed_calls: list[tuple[str, str]] = []
|
||||
self.dropped: list[str] = []
|
||||
self.graphs_built: list[Path] = []
|
||||
#: When set, every batch waits for it: a switch a test holds mid-way.
|
||||
self.pause: asyncio.Event | None = None
|
||||
|
||||
def unavailable_reason(self) -> str | None:
|
||||
return self.unavailable
|
||||
|
||||
async def scan(self, *, per_user: bool = False) -> tuple[ScannedSpace, ...]:
|
||||
grouped: dict[tuple[str, int | None], dict[str, str]] = {}
|
||||
for name in self.store.collections:
|
||||
parsed = parse(name)
|
||||
if parsed is None:
|
||||
continue
|
||||
base, dims, role = parsed
|
||||
grouped.setdefault((base, dims), {})[role] = name
|
||||
out = []
|
||||
for (base, dims), roles in sorted(
|
||||
grouped.items(), key=lambda kv: (kv[0][0], kv[0][1] or 0)
|
||||
):
|
||||
counts = {role: len(self.store.collections[name]) for role, name in roles.items()}
|
||||
widths = {
|
||||
record.width
|
||||
for name in roles.values()
|
||||
for record in self.store.collections[name].values()
|
||||
}
|
||||
users: dict[str, int] = {}
|
||||
if per_user:
|
||||
for name in roles.values():
|
||||
for record in self.store.collections[name].values():
|
||||
user = str(record.metadata.get("user_id") or "")
|
||||
users[user] = users.get(user, 0) + 1
|
||||
out.append(
|
||||
ScannedSpace(
|
||||
collection_base=base,
|
||||
name_dims=dims,
|
||||
width=widths.pop() if len(widths) == 1 else None,
|
||||
counts=counts,
|
||||
collections=roles,
|
||||
per_user=users,
|
||||
)
|
||||
)
|
||||
return tuple(out)
|
||||
|
||||
async def diff(self, source: PhysicalSpace, target: PhysicalSpace) -> dict[str, int]:
|
||||
target_names = target.names()
|
||||
out = {}
|
||||
for role, name in source.names().items():
|
||||
have = set(self.store.collections.get(target_names.get(role, ""), {}))
|
||||
out[role] = len(set(self.store.collections.get(name, {})) - have)
|
||||
return out
|
||||
|
||||
async def reembed(
|
||||
self,
|
||||
source: PhysicalSpace,
|
||||
target: PhysicalSpace,
|
||||
*,
|
||||
embedding: EmbeddingTarget,
|
||||
cancel: Callable[[], bool],
|
||||
on_step: Callable[[ReembedStep], None],
|
||||
batch_size: int = 64,
|
||||
) -> None:
|
||||
del batch_size
|
||||
if target.dims is None:
|
||||
raise ValueError("the target space needs an explicit width")
|
||||
self.reembed_calls.append((source.collection_base, target.collection_base))
|
||||
target_names = target.names()
|
||||
model = embedding.space.model or "?"
|
||||
for role in VECTOR_ROLES:
|
||||
name = source.names().get(role)
|
||||
if name is None or name not in self.store.collections:
|
||||
continue
|
||||
records = sorted(self.store.collections[name].items())
|
||||
destination = self.store.collections.setdefault(target_names[role], {})
|
||||
done = embedded = kept = 0
|
||||
for start in range(0, len(records), self.batch_size):
|
||||
if cancel():
|
||||
raise ReembedCancelledError("embedding_switch_cancelled")
|
||||
self.before_batch(role, done)
|
||||
if self.pause is not None:
|
||||
await self.pause.wait()
|
||||
batch = records[start : start + self.batch_size]
|
||||
width = self.returned_width or target.dims
|
||||
if width != target.dims:
|
||||
raise EmbeddingWidthError("embedding_dimensions_mismatch")
|
||||
for record_id, record in batch:
|
||||
if record_id in destination:
|
||||
kept += 1
|
||||
continue
|
||||
if record_id in self.lose_ids:
|
||||
continue
|
||||
destination[record_id] = Record(
|
||||
document=record.document,
|
||||
metadata=dict(record.metadata),
|
||||
width=width,
|
||||
model=model,
|
||||
)
|
||||
embedded += 1
|
||||
done += len(batch)
|
||||
on_step(
|
||||
ReembedStep(
|
||||
phase="vectors",
|
||||
role=role,
|
||||
done=done,
|
||||
total=len(records),
|
||||
embedded=embedded,
|
||||
kept=kept,
|
||||
)
|
||||
)
|
||||
|
||||
async def verify(
|
||||
self, source: PhysicalSpace, target: PhysicalSpace, *, embedding: EmbeddingTarget
|
||||
) -> VectorCheck:
|
||||
del embedding
|
||||
problems: list[str] = []
|
||||
per_role: dict[str, dict[str, int]] = {}
|
||||
users_source: dict[str, int] = {}
|
||||
users_target: dict[str, int] = {}
|
||||
target_names = target.names()
|
||||
for role, name in source.names().items():
|
||||
src = self.store.collections.get(name, {})
|
||||
tgt = self.store.collections.get(target_names.get(role, ""), {})
|
||||
missing = set(src) - set(tgt)
|
||||
extra = set(tgt) - set(src)
|
||||
mismatched = {
|
||||
key for key in set(src) & set(tgt) if src[key].document != tgt[key].document
|
||||
}
|
||||
bad = sum(1 for record in tgt.values() if record.width != target.dims)
|
||||
per_role[role] = {
|
||||
"source": len(src),
|
||||
"target": len(tgt),
|
||||
"missing": len(missing),
|
||||
"extra": len(extra),
|
||||
"mismatched": len(mismatched),
|
||||
"bad_dims": bad,
|
||||
}
|
||||
for label, value in (
|
||||
("missing", missing),
|
||||
("extra", extra),
|
||||
("mismatched", mismatched),
|
||||
):
|
||||
if value:
|
||||
problems.append(f"{role}: {len(value)} {label}")
|
||||
if bad:
|
||||
problems.append(f"{role}: {bad} bad dims")
|
||||
for record in src.values():
|
||||
user = str(record.metadata.get("user_id") or "")
|
||||
users_source[user] = users_source.get(user, 0) + 1
|
||||
for record in tgt.values():
|
||||
user = str(record.metadata.get("user_id") or "")
|
||||
users_target[user] = users_target.get(user, 0) + 1
|
||||
for user in sorted(set(users_source) | set(users_target)):
|
||||
if users_source.get(user, 0) != users_target.get(user, 0):
|
||||
problems.append(f"user {user!r}: counts differ")
|
||||
return VectorCheck(per_role=per_role, per_user=users_target, problems=tuple(problems))
|
||||
|
||||
async def graph_vectors(self, path: Path) -> int | None:
|
||||
return 3 if path.exists() else None
|
||||
|
||||
async def reembed_graph(
|
||||
self,
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
embedding: EmbeddingTarget,
|
||||
target_dims: int,
|
||||
cancel: Callable[[], bool],
|
||||
on_step: Callable[[ReembedStep], None],
|
||||
) -> None:
|
||||
del embedding, cancel
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / "width").write_text(str(target_dims))
|
||||
self.graphs_built.append(target)
|
||||
on_step(ReembedStep(phase="graph", role="Memory", done=3, total=3, embedded=3))
|
||||
|
||||
async def verify_graph(self, source: Path, target: Path, *, target_dims: int) -> GraphCheck:
|
||||
ok = (target / "width").is_file() and (target / "width").read_text() == str(target_dims)
|
||||
return GraphCheck(ok=ok, problems=() if ok else ("graph width",))
|
||||
|
||||
async def drop(self, space: PhysicalSpace) -> None:
|
||||
for name in space.names().values():
|
||||
if self.store.collections.pop(name, None) is not None:
|
||||
self.dropped.append(name)
|
||||
|
||||
async def drop_graph(self, path: Path) -> None:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
@@ -164,7 +164,16 @@ def test_metadata_repair_never_overlaps_an_sdk_client(tmp_path, monkeypatch):
|
||||
events.append("repair_finished")
|
||||
return MetadataRepairResult(None, 0, 0, 0)
|
||||
|
||||
def open_client(profile, *, api_key, data_dir, http_transport_factory, mode="pro"):
|
||||
def open_client(
|
||||
profile,
|
||||
space=None,
|
||||
*,
|
||||
api_key,
|
||||
data_dir,
|
||||
http_transport_factory,
|
||||
mode="pro",
|
||||
features=(),
|
||||
):
|
||||
events.append("client_opened")
|
||||
return ClientLease(object(), data_dir.resolve())
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,383 @@
|
||||
"""The embedding switch through Core's HTTP surface and lifespan.
|
||||
|
||||
Activation refusals and the empty-home path, a whole switch driven over the
|
||||
API with writes paused and intake still queueing, the startup merge of a
|
||||
stray space before the drain starts, and `embedding_unavailable` on recall
|
||||
and context search when the remote embedding does not answer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory import embedding_health
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ProviderKind
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
from tests.embedding_fakes import FakeReembedBackend
|
||||
|
||||
pytestmark = pytest.mark.fresh_sdk
|
||||
|
||||
ENDPOINT = "https://models.example/v1"
|
||||
|
||||
|
||||
def builtin_base() -> str:
|
||||
identity = "\0".join(("local_onnx", "local", "bge-small-zh-v1.5", "512"))
|
||||
return "memhub_" + hashlib.sha256(identity.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(monkeypatch) -> FakeReembedBackend:
|
||||
fake = FakeReembedBackend()
|
||||
monkeypatch.setattr(
|
||||
"one_memory.embedding_switch.default_reembed_backend", lambda _data_dir: fake
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def application(tmp_path):
|
||||
return create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr("switch-test"),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
),
|
||||
secret_store=InMemorySecretStore(),
|
||||
)
|
||||
|
||||
|
||||
def client_for(app):
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer switch-test"},
|
||||
)
|
||||
|
||||
|
||||
async def recovered(client) -> dict:
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
body = (await client.get("/api/v1/health")).json()
|
||||
if body["recovery"] in {"done", "failed"}:
|
||||
return body
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError("recovery did not finish")
|
||||
|
||||
|
||||
async def until(client, predicate, *, seconds: float = 10.0) -> dict:
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
body = (await client.get("/api/v1/embedding-switches/current")).json()
|
||||
if predicate(body):
|
||||
return body
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError(f"switch never reached the expected state: {body}")
|
||||
|
||||
|
||||
def remote_profile(app, *, model="bge-m3", dims=1024, name="custom", endpoint=ENDPOINT):
|
||||
store, secrets = app.state.model_profile_store, app.state.secret_store
|
||||
created = store.create(
|
||||
ModelProfileInput(
|
||||
name=name,
|
||||
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||
base_url=endpoint,
|
||||
model="chat",
|
||||
embedding_provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||
embedding_base_url=endpoint,
|
||||
embedding_model=model,
|
||||
embedding_dimensions=dims,
|
||||
api_key=SecretStr("sk-test"),
|
||||
),
|
||||
secrets,
|
||||
)
|
||||
store.record_capabilities(
|
||||
created.profile_id,
|
||||
expected_revision=created.revision,
|
||||
capabilities=ModelCapabilities(
|
||||
endpoint_reachable=True,
|
||||
model_available=True,
|
||||
structured_output=True,
|
||||
embedding_available=True,
|
||||
),
|
||||
secrets=secrets,
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
def test_activating_another_embedding_answers_with_the_plan(tmp_path, backend):
|
||||
backend.store.put(builtin_base(), 512, {"a": ("one", "local"), "b": ("two", "bob")})
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await recovered(client)
|
||||
profile = remote_profile(app)
|
||||
|
||||
response = await client.post(f"/api/v1/model-profiles/{profile.profile_id}/activate")
|
||||
|
||||
assert response.status_code == 409
|
||||
body = response.json()
|
||||
assert body["detail"] == "embedding_switch_required"
|
||||
plan = body["plan"]
|
||||
assert plan["needed"] is True and plan["total_vectors"] == 2 and plan["users"] == 2
|
||||
assert plan["source"]["model"] == "bge-small-zh-v1.5"
|
||||
assert (plan["target"]["model"], plan["target"]["dims"]) == ("bge-m3", 1024)
|
||||
assert app.state.model_profile_store.active_identity() is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_an_empty_home_activates_another_embedding_directly(tmp_path, backend):
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await recovered(client)
|
||||
builtin = app.state.embedding_spaces.require_live()
|
||||
profile = remote_profile(app)
|
||||
|
||||
response = await client.post(f"/api/v1/model-profiles/{profile.profile_id}/activate")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["is_active"] is True
|
||||
live = app.state.embedding_spaces.require_live()
|
||||
assert (live.model, live.dims) == ("bge-m3", 1024)
|
||||
assert app.state.embedding_spaces.get(builtin.space_id).state == "dropped"
|
||||
# The same embedding again: a plain activation, no plan.
|
||||
again = remote_profile(app, name="same model, other chat model")
|
||||
assert (
|
||||
await client.post(f"/api/v1/model-profiles/{again.profile_id}/activate")
|
||||
).status_code == 200
|
||||
assert app.state.embedding_spaces.require_live().space_id == live.space_id
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_a_switch_over_the_api_pauses_writes_and_keeps_intake(tmp_path, backend):
|
||||
backend.store.put(
|
||||
builtin_base(),
|
||||
512,
|
||||
{f"m{i}": (f"memory {i}", "local") for i in range(6)},
|
||||
model="bge-small-zh-v1.5",
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await recovered(client)
|
||||
old = app.state.embedding_spaces.require_live()
|
||||
profile = remote_profile(app)
|
||||
planned = await client.post(
|
||||
"/api/v1/embedding-switches/plan", json={"profile_id": profile.profile_id}
|
||||
)
|
||||
assert planned.status_code == 200
|
||||
plan = planned.json()
|
||||
stale = await client.post(
|
||||
"/api/v1/embedding-switches",
|
||||
json={
|
||||
"profile_id": profile.profile_id,
|
||||
"expected_revision": profile.revision,
|
||||
"plan_token": "stale",
|
||||
},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"] == "embedding_switch_plan_stale"
|
||||
|
||||
backend.pause = asyncio.Event()
|
||||
started = await client.post(
|
||||
"/api/v1/embedding-switches",
|
||||
json={
|
||||
"profile_id": profile.profile_id,
|
||||
"expected_revision": profile.revision,
|
||||
"plan_token": plan["plan_token"],
|
||||
"requested_by": "cli",
|
||||
},
|
||||
)
|
||||
assert started.status_code == 202, started.text
|
||||
job_id = started.json()["job_id"]
|
||||
running = await until(
|
||||
client, lambda body: (body["job"] or {}).get("state") == "reembedding"
|
||||
)
|
||||
assert running["writes_paused"] is True and running["phase"] == "running"
|
||||
|
||||
# Direct writers wait; intake keeps queueing; status shows the switch.
|
||||
edit = await client.put(
|
||||
f"/api/v2/memories/{uuid4()}", json={"content": "changed", "user_id": "local"}
|
||||
)
|
||||
assert edit.status_code == 409
|
||||
assert edit.json()["detail"] == "embedding_switch_in_progress"
|
||||
delete = await client.delete(f"/api/v2/memories/{uuid4()}")
|
||||
assert delete.status_code == 409
|
||||
added = await client.post(
|
||||
"/api/v2/memories",
|
||||
json={"request_id": str(uuid4()), "text": "请记住这件事", "scope": "all-apps"},
|
||||
)
|
||||
assert added.status_code == 201
|
||||
processing = (await client.get("/api/v1/memory-processing")).json()
|
||||
assert processing["embedding_switch"]["job_id"] == job_id
|
||||
assert (await client.get("/api/v1/health")).json()["embedding_switch"]["kind"] == (
|
||||
"switch"
|
||||
)
|
||||
other = remote_profile(app, model="text-embedding-v3", dims=768, name="other")
|
||||
refused = await client.post(f"/api/v1/model-profiles/{other.profile_id}/activate")
|
||||
assert refused.json()["detail"] == "embedding_switch_running"
|
||||
|
||||
backend.pause.set()
|
||||
done = await until(
|
||||
client, lambda body: (body["last_finished"] or {}).get("job_id") == job_id
|
||||
)
|
||||
assert done["last_finished"]["state"] == "done"
|
||||
assert done["writes_paused"] is False
|
||||
assert app.state.model_profile_store.active_identity()[0] == profile.profile_id
|
||||
|
||||
spaces = (await client.get("/api/v1/embedding-spaces")).json()
|
||||
states = {space["space_id"]: space["state"] for space in spaces["spaces"]}
|
||||
assert states[old.space_id] == "retired"
|
||||
assert spaces["live_space_id"] == done["last_finished"]["target"]["space_id"]
|
||||
cleaned = await client.delete(f"/api/v1/embedding-spaces/{old.space_id}")
|
||||
assert cleaned.status_code == 200
|
||||
assert cleaned.json()["space"]["state"] == "dropped"
|
||||
live_cleanup = await client.delete(
|
||||
f"/api/v1/embedding-spaces/{spaces['live_space_id']}"
|
||||
)
|
||||
assert live_cleanup.json()["detail"] == "embedding_space_not_retired"
|
||||
|
||||
# Same model and width behind another endpoint: no re-embed.
|
||||
proxied = remote_profile(app, name="proxy", endpoint="https://proxy.example/v1")
|
||||
moved = await client.post(f"/api/v1/model-profiles/{proxied.profile_id}/activate")
|
||||
assert moved.status_code == 200, moved.text
|
||||
assert app.state.embedding_spaces.require_live().space_id == spaces["live_space_id"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_cancel_over_the_api(tmp_path, backend):
|
||||
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await recovered(client)
|
||||
profile = remote_profile(app)
|
||||
plan = (
|
||||
await client.post(
|
||||
"/api/v1/embedding-switches/plan", json={"profile_id": profile.profile_id}
|
||||
)
|
||||
).json()
|
||||
backend.pause = asyncio.Event()
|
||||
job_id = (
|
||||
await client.post(
|
||||
"/api/v1/embedding-switches",
|
||||
json={
|
||||
"profile_id": profile.profile_id,
|
||||
"expected_revision": profile.revision,
|
||||
"plan_token": plan["plan_token"],
|
||||
},
|
||||
)
|
||||
).json()["job_id"]
|
||||
await until(client, lambda body: (body["job"] or {}).get("state") == "reembedding")
|
||||
|
||||
cancelled = await client.post(f"/api/v1/embedding-switches/{job_id}/cancel")
|
||||
assert cancelled.status_code == 200 and cancelled.json()["cancel_requested"]
|
||||
backend.pause.set()
|
||||
done = await until(client, lambda body: body["last_finished"] is not None)
|
||||
|
||||
assert done["last_finished"]["state"] == "rolled_back"
|
||||
assert done["last_finished"]["error_code"] == "embedding_switch_cancelled"
|
||||
missing = await client.post(f"/api/v1/embedding-switches/{uuid4()}/cancel")
|
||||
assert missing.status_code == 404
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_startup_merges_a_stray_space_before_writes_resume(tmp_path, backend):
|
||||
backend.store.put(builtin_base(), 512, {"live-1": ("live", "local")})
|
||||
stray = "memhub_" + "a" * 16
|
||||
backend.store.put(stray, 1024, {"stray-1": ("left by an older build", "hermes-1")})
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
# Core answers at once; the merge runs behind the loading screen.
|
||||
assert (await client.get("/api/v1/health")).status_code == 200
|
||||
await recovered(client)
|
||||
done = await until(client, lambda body: body["last_finished"] is not None)
|
||||
|
||||
assert done["last_finished"]["kind"] == "merge"
|
||||
assert done["last_finished"]["state"] == "done"
|
||||
assert backend.store.ids(builtin_base(), 512) == {"live-1", "stray-1"}
|
||||
spaces = (await client.get("/api/v1/embedding-spaces")).json()
|
||||
stray_row = next(row for row in spaces["spaces"] if row["collection_base"] == stray)
|
||||
assert stray_row["state"] == "retired" and stray_row["keep_until"]
|
||||
assert app.state.embedding_switch.writes_open()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
class _EmptySearchClient:
|
||||
def search(self, *_args, **_kwargs):
|
||||
return {"request_id": "r", "memories": {"schema": [], "proactive": [], "normal": []}}
|
||||
|
||||
def get(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def test_a_dead_remote_embedding_answers_embedding_unavailable(tmp_path, backend, monkeypatch):
|
||||
status = {"code": 503}
|
||||
|
||||
def transport():
|
||||
return httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
status["code"], json={"data": [{"embedding": [0.1] * 4}]}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(embedding_health, "transport_factory", transport)
|
||||
monkeypatch.setattr(
|
||||
"one_memory.hy_sdk_runtime.acquire_client",
|
||||
lambda *args, **kwargs: SimpleNamespace(client=_EmptySearchClient(), close=lambda: None),
|
||||
)
|
||||
|
||||
async def ready(_app):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("one_memory.api._recall_model_ready", ready)
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await recovered(client)
|
||||
profile = remote_profile(app)
|
||||
assert (
|
||||
await client.post(f"/api/v1/model-profiles/{profile.profile_id}/activate")
|
||||
).status_code == 200
|
||||
|
||||
recall = await client.post("/api/v1/recall", json={"query": "数据库"})
|
||||
assert recall.status_code == 503
|
||||
assert recall.json()["detail"] == "embedding_unavailable"
|
||||
context = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "数据库", "user_id": "local", "kinds": ["memory"]},
|
||||
)
|
||||
assert context.status_code == 503
|
||||
assert context.json()["detail"] == "embedding_unavailable"
|
||||
|
||||
# The service answers again: an empty result is just empty.
|
||||
status["code"] = 200
|
||||
app.state.sdk_client_handle.health.forget()
|
||||
recall = await client.post("/api/v1/recall", json={"query": "数据库"})
|
||||
assert recall.status_code == 200
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,604 @@
|
||||
"""The re-embed adapter against the SDK's real re-embed module and Chroma.
|
||||
|
||||
Skipped on an SDK without `hy_memory.reembed` (before 2.0.10): there the
|
||||
switch is unavailable and the plan says `sdk_reembed_unavailable`, which
|
||||
`test_embedding_switch.py` covers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import hy_memory
|
||||
import pytest
|
||||
|
||||
from one_memory.embedding_backend import EmbeddingTarget, PhysicalSpace
|
||||
from one_memory.embedding_spaces import EmbeddingSpace, space_collection_names
|
||||
from one_memory.hy_sdk_migration import SdkReembedBackend
|
||||
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
SdkReembedBackend(Path("/nonexistent")).unavailable_reason() is not None,
|
||||
reason="the installed SDK has no re-embed module (hy-memory < 2.0.10)",
|
||||
)
|
||||
|
||||
SOURCE = "memhub_" + "1" * 16
|
||||
TARGET = "memhub_e_" + "2" * 16
|
||||
|
||||
|
||||
class _Embedder:
|
||||
"""A deterministic 8-wide stand-in for the target model."""
|
||||
|
||||
def __init__(self, width: int = 8) -> None:
|
||||
self.width = width
|
||||
self.texts: list[str] = []
|
||||
|
||||
async def embed_batch(self, texts: list[str], use_cache: bool = True) -> list[list[float]]:
|
||||
self.texts.extend(texts)
|
||||
return [[float(len(text) % 7 + 1)] + [0.5] * (self.width - 1) for text in texts]
|
||||
|
||||
async def embed(self, text: str, use_cache: bool = True) -> list[float]:
|
||||
return (await self.embed_batch([text]))[0]
|
||||
|
||||
def get_embedding_dims(self) -> int:
|
||||
return self.width
|
||||
|
||||
|
||||
def _seed(data_dir: Path) -> None:
|
||||
import chromadb
|
||||
|
||||
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
|
||||
vector.mkdir(parents=True)
|
||||
client = chromadb.PersistentClient(path=str(vector))
|
||||
names = space_collection_names(SOURCE, 4)
|
||||
memory = client.get_or_create_collection(names["memory"], metadata={"hnsw:space": "cosine"})
|
||||
memory.add(
|
||||
ids=["m1", "m2", "m3"],
|
||||
embeddings=[[0.1, 0.2, 0.3, 0.4]] * 3,
|
||||
documents=["one", "two", "three"],
|
||||
metadatas=[{"user_id": "local"}, {"user_id": "hermes-a"}, {"user_id": "hermes-b"}],
|
||||
)
|
||||
keys = client.get_or_create_collection(names["coding_keys"], metadata={"hnsw:space": "cosine"})
|
||||
keys.add(
|
||||
ids=["e1:0"],
|
||||
embeddings=[[0.4, 0.3, 0.2, 0.1]],
|
||||
documents=["task"],
|
||||
metadatas=[{"user_id": "local"}],
|
||||
)
|
||||
|
||||
|
||||
def _target() -> EmbeddingTarget:
|
||||
space = EmbeddingSpace(
|
||||
space_id="target",
|
||||
state="building",
|
||||
model="stand-in",
|
||||
dims=8,
|
||||
name_dims=8,
|
||||
collection_base=TARGET,
|
||||
naming="v2",
|
||||
created_at="2026-11-01T00:00:00+00:00",
|
||||
)
|
||||
return EmbeddingTarget(profile=builtin_retrieval_snapshot(), space=space, api_key=None)
|
||||
|
||||
|
||||
def test_core_names_a_space_exactly_as_the_sdk_does() -> None:
|
||||
import hy_memory
|
||||
|
||||
for dims in (None, 512, 1024):
|
||||
sdk = hy_memory.vector_space_names(hy_memory.VectorSpace(base_name=SOURCE, dims=dims))
|
||||
assert sdk == space_collection_names(SOURCE, dims)
|
||||
|
||||
|
||||
def test_scan_reembed_verify_and_drop_through_the_sdk(tmp_path: Path, monkeypatch) -> None:
|
||||
_seed(tmp_path)
|
||||
backend = SdkReembedBackend(tmp_path)
|
||||
embedder = _Embedder()
|
||||
monkeypatch.setattr(backend, "_embedding_service", lambda _target: embedder)
|
||||
source = PhysicalSpace(collection_base=SOURCE, name_dims=4, dims=4)
|
||||
target = PhysicalSpace(collection_base=TARGET, name_dims=8, dims=8, model="stand-in")
|
||||
steps = []
|
||||
|
||||
async def scenario() -> None:
|
||||
scanned = await backend.scan(per_user=True)
|
||||
found = {item.collection_base: item for item in scanned}
|
||||
assert found[SOURCE].vectors == 4
|
||||
assert found[SOURCE].per_user == {"local": 2, "hermes-a": 1, "hermes-b": 1}
|
||||
|
||||
await backend.reembed(
|
||||
source, target, embedding=_target(), cancel=lambda: False, on_step=steps.append
|
||||
)
|
||||
assert not any((await backend.diff(source, target)).values())
|
||||
check = await backend.verify(source, target, embedding=_target())
|
||||
assert check.problems == ()
|
||||
assert check.per_user == {"local": 2, "hermes-a": 1, "hermes-b": 1}
|
||||
|
||||
# Resumable: a second run embeds nothing more.
|
||||
before = len(embedder.texts)
|
||||
await backend.reembed(
|
||||
source, target, embedding=_target(), cancel=lambda: False, on_step=steps.append
|
||||
)
|
||||
assert len(embedder.texts) == before
|
||||
|
||||
await backend.drop(target)
|
||||
remaining = {item.collection_base for item in await backend.scan()}
|
||||
assert TARGET not in remaining and SOURCE in remaining
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert {step.role for step in steps} >= {"memory", "coding_keys"}
|
||||
|
||||
|
||||
def test_a_wrong_width_fails_before_anything_is_written(tmp_path: Path, monkeypatch) -> None:
|
||||
from one_memory.embedding_backend import EmbeddingWidthError
|
||||
|
||||
_seed(tmp_path)
|
||||
backend = SdkReembedBackend(tmp_path)
|
||||
monkeypatch.setattr(backend, "_embedding_service", lambda _target: _Embedder(width=6))
|
||||
source = PhysicalSpace(collection_base=SOURCE, name_dims=4, dims=4)
|
||||
target = PhysicalSpace(collection_base=TARGET, name_dims=8, dims=8)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(EmbeddingWidthError):
|
||||
await backend.reembed(
|
||||
source, target, embedding=_target(), cancel=lambda: False, on_step=lambda _: None
|
||||
)
|
||||
found = {item.collection_base: item for item in await backend.scan()}
|
||||
assert TARGET not in found or found[TARGET].vectors == 0
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_cancel_stops_at_a_batch_boundary(tmp_path: Path, monkeypatch) -> None:
|
||||
from one_memory.embedding_backend import ReembedCancelledError
|
||||
|
||||
_seed(tmp_path)
|
||||
backend = SdkReembedBackend(tmp_path)
|
||||
monkeypatch.setattr(backend, "_embedding_service", lambda _target: _Embedder())
|
||||
source = PhysicalSpace(collection_base=SOURCE, name_dims=4, dims=4)
|
||||
target = PhysicalSpace(collection_base=TARGET, name_dims=8, dims=8)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(ReembedCancelledError):
|
||||
await backend.reembed(
|
||||
source, target, embedding=_target(), cancel=lambda: True, on_step=lambda _: None
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_a_whole_switch_through_the_real_sdk(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Registry, worker and commit over real Chroma collections and the SDK."""
|
||||
import chromadb
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.embedding_spaces import EmbeddingSpaceStore
|
||||
from one_memory.embedding_switch import EmbeddingSwitchService
|
||||
from one_memory.model_profiles import (
|
||||
ModelCapabilities,
|
||||
ModelProfileInput,
|
||||
ModelProfileStore,
|
||||
ProviderKind,
|
||||
)
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
database = tmp_path / "one-memory.sqlite3"
|
||||
secrets = InMemorySecretStore()
|
||||
profiles = ModelProfileStore(database)
|
||||
spaces = EmbeddingSpaceStore(database)
|
||||
live = spaces.bootstrap(None)
|
||||
vector = tmp_path / "hy-memory" / "governed-v2" / "vector"
|
||||
vector.mkdir(parents=True)
|
||||
client = chromadb.PersistentClient(path=str(vector))
|
||||
memory = client.get_or_create_collection(
|
||||
live.collection_names()["memory"], metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
memory.add(
|
||||
ids=["a", "b", "c"],
|
||||
embeddings=[[0.1] * 512, [0.2] * 512, [0.3] * 512],
|
||||
documents=["one", "two", "three"],
|
||||
metadatas=[{"user_id": "local"}, {"user_id": "hermes-a"}, {"user_id": "local"}],
|
||||
)
|
||||
stray_base = "memhub_" + "9" * 16
|
||||
stray = client.get_or_create_collection(
|
||||
space_collection_names(stray_base, 768)["memory"], metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
stray.add(
|
||||
ids=["a", "z"],
|
||||
embeddings=[[0.5] * 768, [0.6] * 768],
|
||||
documents=["stale", "only in the stray"],
|
||||
metadatas=[{"user_id": "local"}, {"user_id": "hermes-b"}],
|
||||
)
|
||||
created = profiles.create(
|
||||
ModelProfileInput(
|
||||
name="remote",
|
||||
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||
base_url="https://models.example/v1",
|
||||
model="chat",
|
||||
embedding_provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||
embedding_base_url="https://models.example/v1",
|
||||
embedding_model="stand-in",
|
||||
embedding_dimensions=8,
|
||||
api_key=SecretStr("sk-test"),
|
||||
),
|
||||
secrets,
|
||||
)
|
||||
profiles.record_capabilities(
|
||||
created.profile_id,
|
||||
expected_revision=created.revision,
|
||||
capabilities=ModelCapabilities(
|
||||
endpoint_reachable=True,
|
||||
model_available=True,
|
||||
structured_output=True,
|
||||
embedding_available=True,
|
||||
),
|
||||
secrets=secrets,
|
||||
)
|
||||
backend = SdkReembedBackend(tmp_path)
|
||||
monkeypatch.setattr(backend, "_embedding_service", lambda _target: _Embedder())
|
||||
service = EmbeddingSwitchService(
|
||||
database_path=database,
|
||||
data_dir=tmp_path,
|
||||
profiles=profiles,
|
||||
spaces=spaces,
|
||||
secrets=secrets,
|
||||
backend=backend,
|
||||
worker_enabled=False,
|
||||
)
|
||||
service.release_scan_hold()
|
||||
|
||||
async def scenario() -> None:
|
||||
plan = await service.plan(created.profile_id)
|
||||
assert plan.needed and plan.total_vectors == 5 and plan.users == 3
|
||||
job = await service.start_switch(
|
||||
created.profile_id,
|
||||
expected_revision=created.revision,
|
||||
plan_token=plan.plan_token,
|
||||
requested_by="cli",
|
||||
)
|
||||
await service.run(job.job_id)
|
||||
finished = service.get_job(job.job_id)
|
||||
assert finished.state == "done", service.jobs.get(job.job_id)["verification_json"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
new = spaces.require_live()
|
||||
target = client.get_collection(new.collection_names()["memory"])
|
||||
got = target.get(include=["documents", "embeddings"])
|
||||
documents = dict(zip(got["ids"], got["documents"], strict=True))
|
||||
assert documents == {"a": "one", "b": "two", "c": "three", "z": "only in the stray"}
|
||||
assert {len(vector) for vector in got["embeddings"]} == {8}
|
||||
assert profiles.active_identity() == (created.profile_id, created.revision)
|
||||
states = {space.collection_base: space.state for space in spaces.list_spaces()}
|
||||
assert states[live.collection_base] == "retired"
|
||||
assert states[stray_base] == "retired"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Nothing stood in: the SDK's EmbedService over HTTP, real Chroma, real Kuzu
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _EmbeddingServer:
|
||||
"""A local OpenAI-compatible `/embeddings` endpoint; counts what it was sent."""
|
||||
|
||||
def __init__(self, width: int) -> None:
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
self.width = width
|
||||
self.inputs: list[str] = []
|
||||
self.dimensions: list[object] = []
|
||||
server = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None: # noqa: N802 - http.server's name
|
||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
texts = body["input"] if isinstance(body["input"], list) else [body["input"]]
|
||||
server.inputs.extend(texts)
|
||||
server.dimensions.append(body.get("dimensions"))
|
||||
payload = json.dumps(
|
||||
{
|
||||
"object": "list",
|
||||
"model": body["model"],
|
||||
"data": [
|
||||
{
|
||||
"object": "embedding",
|
||||
"index": index,
|
||||
"embedding": [(len(text) % 7 + 1) / 8.0] + [0.5] * (width - 1),
|
||||
}
|
||||
for index, text in enumerate(texts)
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "total_tokens": 1},
|
||||
}
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *_args: object) -> None:
|
||||
return
|
||||
|
||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.url = f"http://127.0.0.1:{self._server.server_port}/v1"
|
||||
threading.Thread(target=self._server.serve_forever, daemon=True).start()
|
||||
|
||||
def close(self) -> None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
|
||||
|
||||
def _home(tmp_path: Path, *, embedding_provider, embedding_url: str, api_key: str | None):
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.embedding_spaces import EmbeddingSpaceStore
|
||||
from one_memory.model_profiles import (
|
||||
ModelCapabilities,
|
||||
ModelProfileInput,
|
||||
ModelProfileStore,
|
||||
ProviderKind,
|
||||
)
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
database = tmp_path / "one-memory.sqlite3"
|
||||
secrets = InMemorySecretStore()
|
||||
profiles = ModelProfileStore(database)
|
||||
spaces = EmbeddingSpaceStore(database)
|
||||
created = profiles.create(
|
||||
ModelProfileInput(
|
||||
name="target",
|
||||
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||
base_url=embedding_url,
|
||||
model="chat",
|
||||
embedding_provider_kind=embedding_provider,
|
||||
embedding_base_url=embedding_url,
|
||||
embedding_model="stand-in",
|
||||
embedding_dimensions=8,
|
||||
api_key=SecretStr(api_key) if api_key else None,
|
||||
),
|
||||
secrets,
|
||||
)
|
||||
profiles.record_capabilities(
|
||||
created.profile_id,
|
||||
expected_revision=created.revision,
|
||||
capabilities=ModelCapabilities(
|
||||
endpoint_reachable=True,
|
||||
model_available=True,
|
||||
structured_output=True,
|
||||
embedding_available=True,
|
||||
),
|
||||
secrets=secrets,
|
||||
)
|
||||
return database, secrets, profiles, spaces, created
|
||||
|
||||
|
||||
def _service(database, data_dir, profiles, spaces, secrets, **options):
|
||||
from one_memory.embedding_switch import EmbeddingSwitchService
|
||||
|
||||
service = EmbeddingSwitchService(
|
||||
database_path=database,
|
||||
data_dir=data_dir,
|
||||
profiles=profiles,
|
||||
spaces=spaces,
|
||||
secrets=secrets,
|
||||
backend=SdkReembedBackend(data_dir),
|
||||
worker_enabled=False,
|
||||
**options,
|
||||
)
|
||||
service.release_scan_hold()
|
||||
return service
|
||||
|
||||
|
||||
async def _switch(service, created) -> object:
|
||||
plan = await service.plan(created.profile_id)
|
||||
assert plan.needed and not plan.blockers, plan
|
||||
job = await service.start_switch(
|
||||
created.profile_id,
|
||||
expected_revision=created.revision,
|
||||
plan_token=plan.plan_token,
|
||||
requested_by="cli",
|
||||
)
|
||||
await service.run(job.job_id)
|
||||
finished = service.get_job(job.job_id)
|
||||
assert finished.state == "done", (
|
||||
finished.error_code,
|
||||
service.jobs.get(job.job_id)["verification_json"],
|
||||
service.jobs.get(job.job_id)["error_detail"],
|
||||
)
|
||||
return finished
|
||||
|
||||
|
||||
def _seed_vectors(data_dir: Path, space) -> None:
|
||||
import chromadb
|
||||
|
||||
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
|
||||
vector.mkdir(parents=True, exist_ok=True)
|
||||
client = chromadb.PersistentClient(path=str(vector))
|
||||
memory = client.get_or_create_collection(
|
||||
space.collection_names()["memory"], metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
memory.add(
|
||||
ids=["a", "b"],
|
||||
embeddings=[[0.1] * 512, [0.2] * 512],
|
||||
documents=["one", "two"],
|
||||
metadatas=[{"user_id": "local"}, {"user_id": "hermes-a"}],
|
||||
)
|
||||
|
||||
|
||||
def test_a_switch_embeds_through_the_sdk_service_over_http(tmp_path: Path) -> None:
|
||||
from one_memory.model_profiles import ProviderKind
|
||||
|
||||
server = _EmbeddingServer(8)
|
||||
try:
|
||||
database, secrets, profiles, spaces, created = _home(
|
||||
tmp_path,
|
||||
embedding_provider=ProviderKind.OPENAI_COMPATIBLE,
|
||||
embedding_url=server.url,
|
||||
api_key="sk-test",
|
||||
)
|
||||
_seed_vectors(tmp_path, spaces.bootstrap(None))
|
||||
service = _service(database, tmp_path, profiles, spaces, secrets)
|
||||
|
||||
asyncio.run(_switch(service, created))
|
||||
|
||||
assert {"one", "two"} <= set(server.inputs)
|
||||
assert set(server.dimensions) == {8}
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
def test_an_ollama_embedding_needs_no_generation_key_to_re_embed(tmp_path: Path) -> None:
|
||||
"""A switch only embeds: the generation model's key is not its business."""
|
||||
from one_memory.embedding_backend import EmbeddingTarget
|
||||
from one_memory.embedding_spaces import embedding_identity
|
||||
from one_memory.model_profiles import ProviderKind
|
||||
|
||||
server = _EmbeddingServer(8)
|
||||
try:
|
||||
_database, secrets, profiles, spaces, created = _home(
|
||||
tmp_path,
|
||||
embedding_provider=ProviderKind.OLLAMA,
|
||||
embedding_url=server.url,
|
||||
api_key="sk-test",
|
||||
)
|
||||
profile = profiles.snapshot(created.profile_id, created.revision)
|
||||
identity = embedding_identity(profile)
|
||||
target = EmbeddingSpace(
|
||||
space_id="t",
|
||||
state="building",
|
||||
model=identity.model,
|
||||
dims=8,
|
||||
name_dims=8,
|
||||
collection_base=TARGET,
|
||||
naming="v2",
|
||||
created_at="2026-11-01T00:00:00+00:00",
|
||||
)
|
||||
backend = SdkReembedBackend(tmp_path)
|
||||
|
||||
# `api_key=None` is what `_embedding_target` resolves for Ollama.
|
||||
service = backend._embedding_service(
|
||||
EmbeddingTarget(profile=profile, space=target, api_key=None)
|
||||
)
|
||||
|
||||
assert asyncio.run(service.embed_batch(["hello"], use_cache=False))[0][1] == 0.5
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
async def _seed_graph(path: Path, dims: int) -> None:
|
||||
from hy_memory.data.graph_store_kuzu import KuzuGraphStore
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryNode
|
||||
|
||||
config = hy_memory.MemoryConfig()
|
||||
config.graph_store.provider = "kuzu"
|
||||
config.graph_store.db_path = str(path)
|
||||
config.vector_store.embedding_dims = dims
|
||||
store = KuzuGraphStore(config)
|
||||
await store.initialize()
|
||||
assert store._available
|
||||
node = MemoryNode(
|
||||
user_id="local",
|
||||
agent_id="a",
|
||||
session_id="s",
|
||||
layer=MemoryLayer.L6_SCHEMA,
|
||||
content="plans ahead",
|
||||
custom={"retrieval_text": "plans trips weeks ahead"},
|
||||
)
|
||||
node._graph_embedding = [0.1] * dims
|
||||
await store.upsert_memory_node(node)
|
||||
await store.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["ultra", "pro"])
|
||||
def test_a_real_graph_moves_to_the_target_width(tmp_path: Path, mode: str) -> None:
|
||||
"""In any mode: a pro home's old graph must not be lost with the old space."""
|
||||
from one_memory.hy_graph_admin import forget_graph_widths
|
||||
from one_memory.model_profiles import ProviderKind
|
||||
|
||||
server = _EmbeddingServer(8)
|
||||
try:
|
||||
database, secrets, profiles, spaces, created = _home(
|
||||
tmp_path,
|
||||
embedding_provider=ProviderKind.OPENAI_COMPATIBLE,
|
||||
embedding_url=server.url,
|
||||
api_key="sk-test",
|
||||
)
|
||||
live = spaces.bootstrap(None)
|
||||
_seed_vectors(tmp_path, live)
|
||||
root = tmp_path / "hy-memory" / "governed-v2"
|
||||
asyncio.run(_seed_graph(root / "graph", 512))
|
||||
service = _service(
|
||||
database, tmp_path, profiles, spaces, secrets, graph_enabled=lambda: mode == "ultra"
|
||||
)
|
||||
|
||||
asyncio.run(_switch(service, created))
|
||||
|
||||
new = spaces.require_live()
|
||||
assert new.graph_dir != "graph" and (root / new.graph_dir).exists()
|
||||
assert (root / "graph").exists()
|
||||
assert "plans trips weeks ahead" in server.inputs
|
||||
forget_graph_widths()
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
def test_verifying_the_graph_closes_what_reads_reopened(tmp_path: Path) -> None:
|
||||
"""Reads are served during a switch, so a client may hold the graph again."""
|
||||
from one_memory.model_profiles import ProviderKind
|
||||
|
||||
server = _EmbeddingServer(8)
|
||||
quiesced: list[str] = []
|
||||
|
||||
async def quiesce():
|
||||
quiesced.append("closed")
|
||||
return lambda: quiesced.append("released")
|
||||
|
||||
try:
|
||||
database, secrets, profiles, spaces, created = _home(
|
||||
tmp_path,
|
||||
embedding_provider=ProviderKind.OPENAI_COMPATIBLE,
|
||||
embedding_url=server.url,
|
||||
api_key="sk-test",
|
||||
)
|
||||
live = spaces.bootstrap(None)
|
||||
_seed_vectors(tmp_path, live)
|
||||
asyncio.run(_seed_graph(tmp_path / "hy-memory" / "governed-v2" / "graph", 512))
|
||||
service = _service(
|
||||
database,
|
||||
tmp_path,
|
||||
profiles,
|
||||
spaces,
|
||||
secrets,
|
||||
graph_enabled=lambda: True,
|
||||
quiesce_clients=quiesce,
|
||||
)
|
||||
|
||||
asyncio.run(_switch(service, created))
|
||||
|
||||
# Once to copy the graph, once to verify it.
|
||||
assert quiesced == ["closed", "released", "closed", "released"]
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
def test_the_first_chroma_open_waits_for_a_client_under_construction(tmp_path: Path) -> None:
|
||||
"""Two first opens on one directory race over Chroma's unlocked System cache."""
|
||||
import threading
|
||||
|
||||
from one_memory.hy_sdk_runtime import first_store_open
|
||||
|
||||
_seed(tmp_path)
|
||||
backend = SdkReembedBackend(tmp_path)
|
||||
finished = threading.Event()
|
||||
|
||||
def scan() -> None:
|
||||
asyncio.run(backend.scan())
|
||||
finished.set()
|
||||
|
||||
with first_store_open(tmp_path):
|
||||
thread = threading.Thread(target=scan)
|
||||
thread.start()
|
||||
assert not finished.wait(0.3)
|
||||
thread.join(10)
|
||||
assert finished.is_set()
|
||||
@@ -48,6 +48,7 @@ def test_health_reports_the_single_core() -> None:
|
||||
# No lifespan ran, so recovery has not begun.
|
||||
"recovery": "pending",
|
||||
"recovery_failed_steps": [],
|
||||
"embedding_switch": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ ALLOWED = {
|
||||
"the offline migration opens Chroma",
|
||||
opened_with="?mode=ro",
|
||||
),
|
||||
("sdk_vector_reattach.py", "stored_collection_width"): Allowed(
|
||||
"Chroma's own chroma.sqlite3, read-only, for the width it fixed on a "
|
||||
"collection: a probe on the live space's profile may only record that one",
|
||||
opened_with="?mode=ro",
|
||||
),
|
||||
("imports/sqlite_access.py", "open_sqlite_readonly"): Allowed(
|
||||
"a host app's own SQLite (Cursor state.vscdb / store.db): one direct "
|
||||
"read-only open when there is no WAL, and one read-only open of the "
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
"""The SDK retrieval and extraction features MemHub lets a person turn on.
|
||||
|
||||
Graph rerank (usage A), episode recall (usage B), the episode write side and
|
||||
the extraction switches are `MemoryConfig` fields. MemHub hard-codes none of
|
||||
them: one global `memory_features` document sets them per client in
|
||||
`build_sdk_config`, an unset one keeps the SDK default (so nothing set is the
|
||||
config of before, byte for byte), and a change rebuilds the handles the way a
|
||||
mode change does. A feature never touches the vector collection identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from one_memory import hy_sdk_runtime, sdk_write_processor
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.memory_features import (
|
||||
FEATURE_NAMES,
|
||||
FEATURES,
|
||||
FEATURES_BY_NAME,
|
||||
MemoryFeatures,
|
||||
effective_features,
|
||||
feature_activity,
|
||||
)
|
||||
from one_memory.memory_server.config_state import ConfigStore
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
TOKEN = "memory-features-test"
|
||||
|
||||
#: What MemHub ran with before the setting existed, on a clean environment.
|
||||
TODAY = {
|
||||
"graph_rerank": False,
|
||||
"same_series_timeline": False,
|
||||
"episode_recall": False,
|
||||
"session_episodes": False,
|
||||
"episode_summary": False,
|
||||
"intention": True,
|
||||
"few_shot": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_sdk_env(monkeypatch):
|
||||
"""The SDK's own env vars feed its defaults; tests start without them."""
|
||||
for spec in FEATURES:
|
||||
monkeypatch.delenv(spec.env, raising=False)
|
||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||
|
||||
|
||||
def _remote_profile() -> ModelProfileSnapshot:
|
||||
return ModelProfileSnapshot(
|
||||
profile_id="remote",
|
||||
revision=3,
|
||||
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||
base_url="https://models.example.test/v1",
|
||||
model="chat-model",
|
||||
embedding_provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||
embedding_model="embed-model",
|
||||
embedding_dimensions=1024,
|
||||
timeout_seconds=60,
|
||||
max_concurrency=1,
|
||||
context_window=None,
|
||||
has_api_key=True,
|
||||
# Since #255 the embedding endpoint is its own field, never inherited.
|
||||
embedding_base_url="https://models.example.test/v1",
|
||||
)
|
||||
|
||||
|
||||
PROFILES = (hy_sdk_runtime.builtin_retrieval_snapshot(), _remote_profile())
|
||||
|
||||
|
||||
def _config(tmp_path: Path, profile: ModelProfileSnapshot, features=()):
|
||||
return hy_sdk_runtime.build_sdk_config(
|
||||
profile, api_key="secret-key", data_dir=tmp_path, features=features
|
||||
)
|
||||
|
||||
|
||||
def _field(config, name: str) -> bool:
|
||||
spec = FEATURES_BY_NAME[name]
|
||||
return getattr(getattr(config, spec.section), spec.field)
|
||||
|
||||
|
||||
# --- the catalog and the document ------------------------------------------
|
||||
|
||||
|
||||
def test_the_document_names_exactly_the_catalog() -> None:
|
||||
assert tuple(MemoryFeatures.model_fields) == FEATURE_NAMES
|
||||
assert set(TODAY) == set(FEATURE_NAMES)
|
||||
# Each feature is a MemoryConfig field the SDK has; no env var is written.
|
||||
defaults = hy_sdk_runtime.sdk_feature_defaults()
|
||||
assert set(defaults) == set(FEATURE_NAMES)
|
||||
|
||||
|
||||
def test_the_defaults_are_todays_behaviour() -> None:
|
||||
assert hy_sdk_runtime.sdk_feature_defaults() == TODAY
|
||||
assert MemoryFeatures().overrides() == ()
|
||||
assert effective_features((), TODAY) == TODAY
|
||||
|
||||
|
||||
def test_the_sdk_env_var_still_moves_the_default_of_an_unset_feature(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MEMORY_EPISODE_RECALL_ENABLED", "true")
|
||||
assert hy_sdk_runtime.sdk_feature_defaults()["episode_recall"] is True
|
||||
|
||||
|
||||
def test_unknown_or_non_boolean_values_are_refused() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
MemoryFeatures.model_validate({"graph_rerank": True, "local_rerank": True})
|
||||
with pytest.raises(ValidationError):
|
||||
MemoryFeatures.model_validate({"graph_rerank": "true"})
|
||||
with pytest.raises(ValidationError):
|
||||
MemoryFeatures.model_validate({"graph_rerank": 1})
|
||||
with pytest.raises(ValueError, match="unknown_memory_feature"):
|
||||
hy_sdk_runtime.build_sdk_config(
|
||||
PROFILES[0], api_key=None, data_dir=Path("/tmp/unused"), features={"nope": True}
|
||||
)
|
||||
|
||||
|
||||
def test_merge_sets_resets_and_leaves_absent_keys_alone() -> None:
|
||||
saved = MemoryFeatures(graph_rerank=True, few_shot=True)
|
||||
update = MemoryFeatures.model_validate({"few_shot": None, "episode_recall": True})
|
||||
merged = saved.merged(update)
|
||||
assert merged.overrides() == (("episode_recall", True), ("graph_rerank", True))
|
||||
assert saved.merged(MemoryFeatures()) == saved
|
||||
|
||||
|
||||
def test_the_setting_persists_and_an_untouched_install_writes_none(tmp_path) -> None:
|
||||
store = ConfigStore(tmp_path)
|
||||
store.set_sync("cursor", True)
|
||||
assert "memory_features" not in json.loads((tmp_path / "config.json").read_text())
|
||||
store.set_memory_features(MemoryFeatures(graph_rerank=True, intention=False))
|
||||
saved = json.loads((tmp_path / "config.json").read_text())
|
||||
# Only what a person set; an absent key is the SDK default.
|
||||
assert saved["memory_features"] == {"graph_rerank": True, "intention": False}
|
||||
assert ConfigStore(tmp_path).memory_features().overrides() == (
|
||||
("graph_rerank", True),
|
||||
("intention", False),
|
||||
)
|
||||
(tmp_path / "config.json").write_text(json.dumps({"memory_features": {"bogus": True}}))
|
||||
with pytest.raises(Exception, match="config_invalid"):
|
||||
ConfigStore(tmp_path)
|
||||
|
||||
|
||||
# --- how settings reach the SDK ----------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
||||
def test_each_feature_reaches_the_built_memory_config(tmp_path, profile) -> None:
|
||||
for spec in FEATURES:
|
||||
flipped = not TODAY[spec.name]
|
||||
config = _config(tmp_path, profile, {spec.name: flipped})
|
||||
assert _field(config, spec.name) is flipped, spec.name
|
||||
for other in FEATURES:
|
||||
if other.name != spec.name:
|
||||
assert _field(config, other.name) is TODAY[other.name], (spec.name, other.name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
||||
def test_nothing_set_is_the_config_of_before_byte_for_byte(tmp_path, profile) -> None:
|
||||
before = repr(dataclasses.asdict(_config(tmp_path, profile)))
|
||||
assert repr(dataclasses.asdict(_config(tmp_path, profile, ()))) == before
|
||||
assert repr(dataclasses.asdict(_config(tmp_path, profile, {}))) == before
|
||||
# Setting every feature to its default value changes nothing either.
|
||||
assert repr(dataclasses.asdict(_config(tmp_path, profile, TODAY))) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
||||
def test_features_never_change_the_collection_identity(tmp_path, profile) -> None:
|
||||
def identity(config):
|
||||
return (
|
||||
dataclasses.asdict(config.vector_store),
|
||||
config.embedder.model,
|
||||
config.embedder.embedding_dims,
|
||||
config.graph_store.db_path,
|
||||
config.cache.db_path,
|
||||
)
|
||||
|
||||
baseline = identity(_config(tmp_path, profile))
|
||||
for values in (
|
||||
{name: True for name in FEATURE_NAMES},
|
||||
{name: False for name in FEATURE_NAMES},
|
||||
{"graph_rerank": True, "episode_recall": True},
|
||||
):
|
||||
assert identity(_config(tmp_path, profile, values)) == baseline
|
||||
|
||||
|
||||
def test_acquire_client_builds_the_client_with_the_features(tmp_path, monkeypatch) -> None:
|
||||
built: list[tuple[str, bool, bool]] = []
|
||||
|
||||
class Client:
|
||||
def __init__(self, *, config, mode, runtime, embedding_service):
|
||||
built.append(
|
||||
(
|
||||
mode,
|
||||
config.graph_store.graph_rerank_enabled,
|
||||
config.recall.episode_recall_enabled,
|
||||
)
|
||||
)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(hy_sdk_runtime, "HyMemoryClient", Client)
|
||||
monkeypatch.setattr(hy_sdk_runtime, "_acquire_runtime", lambda config, key: object())
|
||||
monkeypatch.setattr(hy_sdk_runtime, "_release_runtime", lambda key: None)
|
||||
monkeypatch.setattr(hy_sdk_runtime, "inspect_graph_width_mismatch", lambda config: None)
|
||||
profile = hy_sdk_runtime.builtin_retrieval_snapshot()
|
||||
hy_sdk_runtime.acquire_client(profile, api_key=None, data_dir=tmp_path, mode="pro").close()
|
||||
hy_sdk_runtime.acquire_client(
|
||||
profile,
|
||||
api_key=None,
|
||||
data_dir=tmp_path,
|
||||
mode="ultra",
|
||||
features=(("episode_recall", True), ("graph_rerank", True)),
|
||||
).close()
|
||||
assert built == [("pro", False, False), ("ultra", True, True)]
|
||||
|
||||
|
||||
# --- active in the current mode ---------------------------------------------
|
||||
|
||||
|
||||
def test_a_feature_needing_a_higher_mode_is_reported_inactive() -> None:
|
||||
on = effective_features((("graph_rerank", True), ("episode_recall", True)), TODAY)
|
||||
assert feature_activity("graph_rerank", on, "pro") == "requires_ultra"
|
||||
assert feature_activity("graph_rerank", on, "ultra") is None
|
||||
# Usage B needs no graph: it acts in pro, not in lite (nothing extracted).
|
||||
assert feature_activity("episode_recall", on, "pro") is None
|
||||
assert feature_activity("episode_recall", on, "lite") == "requires_pro"
|
||||
assert feature_activity("intention", TODAY, "lite") == "requires_pro"
|
||||
assert feature_activity("few_shot", TODAY, "ultra") == "disabled"
|
||||
series = effective_features((("same_series_timeline", True),), TODAY)
|
||||
assert feature_activity("same_series_timeline", series, "ultra") == "requires_graph_rerank"
|
||||
series = effective_features((("same_series_timeline", True), ("graph_rerank", True)), TODAY)
|
||||
assert feature_activity("same_series_timeline", series, "pro") == "requires_ultra"
|
||||
assert feature_activity("same_series_timeline", series, "ultra") is None
|
||||
|
||||
|
||||
# --- rebuilding handles -------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeHandle:
|
||||
def __init__(self, mode: str, features=(), profile_id: str = "p") -> None:
|
||||
self.mode = mode
|
||||
self.features = features
|
||||
self.profile_id = profile_id
|
||||
self.closed = False
|
||||
|
||||
async def client(self):
|
||||
return self
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_the_read_handle_rebuilds_its_client_when_the_features_change(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
"""Reads follow the active profile (#255), the mode and the features."""
|
||||
from one_memory import sdk_read_handle
|
||||
|
||||
features = [()]
|
||||
made: list[_FakeHandle] = []
|
||||
|
||||
def handle_for(profile, *, api_key, data_dir, mode=None, features=()):
|
||||
handle = _FakeHandle(mode or "pro", features, profile.profile_id)
|
||||
made.append(handle)
|
||||
return handle
|
||||
|
||||
monkeypatch.setattr(sdk_read_handle, "SdkClientHandle", handle_for)
|
||||
|
||||
class _NoProfile:
|
||||
def active_identity(self):
|
||||
return None
|
||||
|
||||
reads = sdk_read_handle.ActiveProfileReadHandle(
|
||||
profiles=_NoProfile(), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=tmp_path,
|
||||
memory_mode=lambda: "pro",
|
||||
features=lambda: features[0],
|
||||
)
|
||||
hold = asyncio.Event()
|
||||
holding = asyncio.Event()
|
||||
|
||||
async def read():
|
||||
return await reads.client()
|
||||
|
||||
async def slow_read():
|
||||
client = await reads.client()
|
||||
holding.set()
|
||||
await hold.wait()
|
||||
return client
|
||||
|
||||
async def scenario():
|
||||
in_flight = asyncio.create_task(slow_read())
|
||||
await asyncio.wait_for(holding.wait(), 5)
|
||||
old = made[0]
|
||||
assert (await asyncio.create_task(read())) is old
|
||||
features[0] = (("episode_recall", True),)
|
||||
try:
|
||||
fresh = await asyncio.create_task(read())
|
||||
assert fresh.features == (("episode_recall", True),) and fresh.mode == "pro"
|
||||
# A read that took the old client is still using it.
|
||||
assert not old.closed
|
||||
finally:
|
||||
hold.set()
|
||||
await in_flight
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
assert old.closed and not fresh.closed
|
||||
await reads.close()
|
||||
assert fresh.closed
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
class _Profiles:
|
||||
def active_identity(self):
|
||||
return ("profile-a", 1)
|
||||
|
||||
def snapshot(self, profile_id, revision):
|
||||
return SimpleNamespace(profile_id=profile_id, has_api_key=False)
|
||||
|
||||
def resolve_snapshot_secret(self, profile_id, revision, secrets):
|
||||
return None
|
||||
|
||||
|
||||
def test_a_feature_change_keeps_a_busy_write_handle_until_its_write_ends(monkeypatch) -> None:
|
||||
features = [()]
|
||||
opened: list[_FakeHandle] = []
|
||||
|
||||
def handle(profile, **kwargs):
|
||||
value = _FakeHandle(kwargs["mode"], kwargs["features"], profile.profile_id)
|
||||
opened.append(value)
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(sdk_write_processor, "SdkClientHandle", handle)
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=_Profiles(), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: True,
|
||||
memory_mode=lambda: "pro",
|
||||
memory_features=lambda: features[0],
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
async with coordinator.use_client() as client:
|
||||
assert client.features == ()
|
||||
features[0] = (("few_shot", True),)
|
||||
await coordinator.retire_stale_handles()
|
||||
# In flight: it finishes with the features it started with (#218).
|
||||
assert not opened[0].closed
|
||||
assert opened[0].closed
|
||||
async with coordinator.use_client() as client:
|
||||
assert client.features == (("few_shot", True),)
|
||||
features[0] = ()
|
||||
await coordinator.retire_stale_handles()
|
||||
assert opened[1].closed
|
||||
await coordinator.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
# --- the HTTP surface -----------------------------------------------------------
|
||||
|
||||
|
||||
def application(tmp_path: Path):
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr(TOKEN),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
)
|
||||
return create_app(settings, secret_store=InMemorySecretStore())
|
||||
|
||||
|
||||
def run_app(tmp_path, monkeypatch, scenario) -> None:
|
||||
async def main() -> None:
|
||||
app = application(tmp_path)
|
||||
|
||||
@asynccontextmanager
|
||||
async def write_client():
|
||||
yield SimpleNamespace()
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
) as client:
|
||||
await scenario(app, client)
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
def _by_name(body) -> dict[str, dict]:
|
||||
return {item["name"]: item for item in body["features"]}
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_features_api_get_put_reset_and_status(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, client):
|
||||
current = (await client.get("/api/v1/settings/memory-features")).json()
|
||||
assert current["mode"] == "pro" and current["non_default"] == {}
|
||||
listed = _by_name(current)
|
||||
assert list(listed) == list(FEATURE_NAMES)
|
||||
assert {name: item["enabled"] for name, item in listed.items()} == TODAY
|
||||
assert all(item["configured"] is None for item in listed.values())
|
||||
rerank = listed["graph_rerank"]
|
||||
assert rerank["default"] is False and rerank["active"] is False
|
||||
assert rerank["reason"] == "disabled" and rerank["requires_mode"] == "ultra"
|
||||
assert rerank["sdk_field"] == "graph_store.graph_rerank_enabled"
|
||||
assert rerank["env"] == "MEMORY_GRAPH_RERANK_ENABLED"
|
||||
assert listed["intention"]["active"] is True
|
||||
assert listed["episode_summary"]["cost"] == "llm_call"
|
||||
reads = app.state.sdk_client_handle
|
||||
# The read handle is keyed by (profile, mode, features): what it
|
||||
# builds next is what these return.
|
||||
assert reads.current_features() == ()
|
||||
|
||||
changed = await client.put(
|
||||
"/api/v1/settings/memory-features",
|
||||
json={"graph_rerank": True, "episode_recall": True},
|
||||
)
|
||||
assert changed.status_code == 200, changed.text
|
||||
body = changed.json()
|
||||
assert body["changed"] == ["graph_rerank", "episode_recall"]
|
||||
listed = _by_name(body)
|
||||
# Enabled but the graph store is ultra only: reported, not hidden.
|
||||
assert listed["graph_rerank"]["enabled"] is True
|
||||
assert listed["graph_rerank"]["active"] is False
|
||||
assert listed["graph_rerank"]["reason"] == "requires_ultra"
|
||||
assert listed["episode_recall"]["active"] is True
|
||||
assert body["non_default"] == {"graph_rerank": True, "episode_recall": True}
|
||||
# The handles were rebuilt with the new features.
|
||||
expected = (("episode_recall", True), ("graph_rerank", True))
|
||||
assert app.state.sdk_write_coordinator.memory_features() == expected
|
||||
assert reads.current_features() == expected
|
||||
assert reads.memory_mode() == "pro"
|
||||
|
||||
processing = (await client.get("/api/v1/memory-processing")).json()
|
||||
assert processing["pipeline"]["features"] == {
|
||||
"graph_rerank": True,
|
||||
"episode_recall": True,
|
||||
}
|
||||
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||
await app.state.memory_graph_backfill.wait()
|
||||
ultra = _by_name((await client.get("/api/v1/settings/memory-features")).json())
|
||||
assert ultra["graph_rerank"]["active"] is True
|
||||
assert ultra["graph_rerank"]["reason"] is None
|
||||
|
||||
again = (
|
||||
await client.put("/api/v1/settings/memory-features", json={"graph_rerank": True})
|
||||
).json()
|
||||
assert again["changed"] == []
|
||||
assert reads.current_features() == expected
|
||||
|
||||
reset = (
|
||||
await client.put(
|
||||
"/api/v1/settings/memory-features",
|
||||
json={"graph_rerank": None, "episode_recall": None},
|
||||
)
|
||||
).json()
|
||||
assert reset["changed"] == ["graph_rerank", "episode_recall"]
|
||||
assert reset["non_default"] == {}
|
||||
assert reads.current_features() == ()
|
||||
saved = json.loads((tmp_path / "data" / "config.json").read_text())
|
||||
assert saved["memory_features"] == {}
|
||||
|
||||
for bad in (
|
||||
{"local_rerank": True},
|
||||
{"graph_rerank": "true"},
|
||||
{"graph_rerank": 1},
|
||||
{"features": {"graph_rerank": True}},
|
||||
):
|
||||
response = await client.put("/api/v1/settings/memory-features", json=bad)
|
||||
assert response.status_code == 422, bad
|
||||
assert (
|
||||
await client.get("/api/v1/settings/memory-features", params={"user_id": "x"})
|
||||
).status_code == 422
|
||||
assert (await client.get("/api/v1/settings/memory-features")).json()["non_default"] == {}
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
@@ -318,6 +318,12 @@ def run_with_sdk(tmp_path, monkeypatch, scenario):
|
||||
async with app.router.lifespan_context(app):
|
||||
app.state.sdk_client_handle = Handle()
|
||||
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
|
||||
# Edits and deletes wait for the startup scan of embedding spaces,
|
||||
# as they would behind the App's loading screen.
|
||||
for _ in range(500):
|
||||
if app.state.embedding_switch.writes_open():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
async with client_for(app) as client:
|
||||
await scenario(app, sdk, client)
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ def test_the_read_handle_rebuilds_on_a_mode_or_profile_change(monkeypatch, tmp_p
|
||||
modes = ["pro"]
|
||||
made: list[_FakeHandle] = []
|
||||
|
||||
def handle_for(profile, *, api_key, data_dir, mode=None):
|
||||
def handle_for(profile, *, api_key, data_dir, mode=None, features=()):
|
||||
handle = _FakeHandle(mode or "pro", profile.profile_id)
|
||||
made.append(handle)
|
||||
return handle
|
||||
@@ -684,6 +684,12 @@ def run_app(tmp_path, monkeypatch, scenario) -> None:
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
|
||||
# Model writes wait for the startup scan of embedding spaces, as
|
||||
# they would behind the App's loading screen.
|
||||
for _ in range(500):
|
||||
if app.state.embedding_switch.writes_open():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
async with client_for(app) as client:
|
||||
await scenario(app, sdk, client)
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.embedding_spaces import EmbeddingIdentity
|
||||
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
|
||||
from one_memory.memory_server.durable_queue import (
|
||||
CANCELLED,
|
||||
@@ -1226,6 +1228,10 @@ def _coordinator(monkeypatch, opened: list[_TrackedHandle]):
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(sdk_write_processor, "SdkClientHandle", handle)
|
||||
# One live embedding space the stand-in profiles all fill.
|
||||
identity = EmbeddingIdentity(model="stand-in")
|
||||
monkeypatch.setattr(sdk_write_processor, "embedding_identity", lambda _profile: identity)
|
||||
space = SimpleNamespace(space_id="space", identity=identity)
|
||||
profiles = _SwitchableProfiles()
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=profiles, # type: ignore[arg-type]
|
||||
@@ -1233,6 +1239,7 @@ def _coordinator(monkeypatch, opened: list[_TrackedHandle]):
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: True,
|
||||
spaces=SimpleNamespace(live=lambda: space), # type: ignore[arg-type]
|
||||
)
|
||||
return coordinator, profiles
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
|
||||
assert manifest["bundle_id"] == "migration-1"
|
||||
assert manifest["sdk_namespace"] == "governed-v2"
|
||||
assert manifest["runtime_build"] == "build-13"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.9"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.10"
|
||||
paths = {item["path"]: item for item in manifest["files"]}
|
||||
assert {
|
||||
"management/one-memory.sqlite3",
|
||||
|
||||
@@ -140,10 +140,20 @@ class _Recorder:
|
||||
self.opened: list[tuple[str, int | None, str | None]] = []
|
||||
self.closed: list[str] = []
|
||||
|
||||
def __call__(self, profile, *, api_key, data_dir, http_transport_factory=None, mode="pro"):
|
||||
def __call__(
|
||||
self,
|
||||
profile,
|
||||
space=None,
|
||||
*,
|
||||
api_key,
|
||||
data_dir,
|
||||
http_transport_factory=None,
|
||||
mode=None,
|
||||
features=(),
|
||||
):
|
||||
from one_memory.hy_sdk_runtime import build_sdk_config
|
||||
|
||||
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
|
||||
config = build_sdk_config(profile, space, mode, api_key=api_key, data_dir=data_dir)
|
||||
name = config.vector_store.collection_name
|
||||
self.opened.append((name, config.vector_store.embedding_dims, api_key))
|
||||
return SimpleNamespace(
|
||||
@@ -153,10 +163,11 @@ class _Recorder:
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reads_open_the_collection_the_active_profile_writes(
|
||||
async def test_reads_open_the_live_space_the_active_profile_writes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""#242: a 2560-dim profile wrote `memhub_<sha>_2560`, reads opened `_512`."""
|
||||
from one_memory.embedding_spaces import EmbeddingSpaceStore
|
||||
from one_memory.hy_sdk_runtime import build_sdk_config
|
||||
from one_memory.model_profiles import ModelProfileStore
|
||||
from one_memory.sdk_read_handle import ActiveProfileReadHandle
|
||||
@@ -166,13 +177,14 @@ async def test_reads_open_the_collection_the_active_profile_writes(
|
||||
monkeypatch.setattr("one_memory.hy_sdk_runtime.acquire_client", recorder)
|
||||
profiles = ModelProfileStore(tmp_path / "one-memory.sqlite3")
|
||||
secrets = InMemorySecretStore()
|
||||
handle = ActiveProfileReadHandle(profiles=profiles, secrets=secrets, data_dir=tmp_path)
|
||||
|
||||
builtin = build_sdk_config(builtin_retrieval_snapshot(), api_key=None, data_dir=tmp_path)
|
||||
assert (await handle.client()).collection == builtin.vector_store.collection_name
|
||||
|
||||
# A home whose custom profile was active when this build first started:
|
||||
# the registry pins the collection that profile has been writing.
|
||||
written = _activate(profiles, secrets, dimensions=2560)
|
||||
spaces = EmbeddingSpaceStore(profiles.database_path)
|
||||
spaces.bootstrap(written)
|
||||
handle = ActiveProfileReadHandle(profiles=profiles, secrets=secrets, data_dir=tmp_path)
|
||||
writes = build_sdk_config(written, api_key="sk-test", data_dir=tmp_path)
|
||||
builtin = build_sdk_config(builtin_retrieval_snapshot(), api_key=None, data_dir=tmp_path)
|
||||
|
||||
async def read() -> str:
|
||||
return str((await handle.client()).collection)
|
||||
@@ -184,9 +196,11 @@ async def test_reads_open_the_collection_the_active_profile_writes(
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_profile_change_rebuilds_reads_without_closing_one_in_flight(
|
||||
async def test_an_activation_that_skips_the_switch_never_moves_reads(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Only a switch's commit moves the space; reads stay where the data is."""
|
||||
from one_memory.embedding_spaces import EmbeddingSpaceStore
|
||||
from one_memory.model_profiles import ModelProfileStore
|
||||
from one_memory.sdk_read_handle import ActiveProfileReadHandle
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
@@ -195,8 +209,40 @@ async def test_a_profile_change_rebuilds_reads_without_closing_one_in_flight(
|
||||
monkeypatch.setattr("one_memory.hy_sdk_runtime.acquire_client", recorder)
|
||||
profiles = ModelProfileStore(tmp_path / "one-memory.sqlite3")
|
||||
secrets = InMemorySecretStore()
|
||||
first = _activate(profiles, secrets, dimensions=1024)
|
||||
live = EmbeddingSpaceStore(profiles.database_path).bootstrap(first)
|
||||
handle = ActiveProfileReadHandle(profiles=profiles, secrets=secrets, data_dir=tmp_path)
|
||||
_activate(profiles, secrets, dimensions=1024)
|
||||
_activate(profiles, secrets, dimensions=2560, model="chat-2")
|
||||
|
||||
collection = (await asyncio.create_task(handle.client())).collection
|
||||
|
||||
assert collection == live.collection_base
|
||||
await handle.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_committed_switch_rebuilds_reads_without_closing_one_in_flight(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from one_memory.embedding_spaces import (
|
||||
EmbeddingSpaceStore,
|
||||
derived_space,
|
||||
insert_space_in,
|
||||
set_state_in,
|
||||
)
|
||||
from one_memory.model_profiles import ModelProfileStore
|
||||
from one_memory.sdk_read_handle import ActiveProfileReadHandle
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
recorder = _Recorder(tmp_path)
|
||||
monkeypatch.setattr("one_memory.hy_sdk_runtime.acquire_client", recorder)
|
||||
profiles = ModelProfileStore(tmp_path / "one-memory.sqlite3")
|
||||
secrets = InMemorySecretStore()
|
||||
spaces = EmbeddingSpaceStore(profiles.database_path)
|
||||
old_space = spaces.bootstrap(_activate(profiles, secrets, dimensions=1024))
|
||||
handle = ActiveProfileReadHandle(
|
||||
profiles=profiles, secrets=secrets, data_dir=tmp_path, spaces=spaces
|
||||
)
|
||||
|
||||
holding = asyncio.Event()
|
||||
finish = asyncio.Event()
|
||||
@@ -211,7 +257,15 @@ async def test_a_profile_change_rebuilds_reads_without_closing_one_in_flight(
|
||||
await holding.wait()
|
||||
[(old, _, _)] = recorder.opened
|
||||
|
||||
_activate(profiles, secrets, dimensions=2560, model="chat-2")
|
||||
# What a switch's commit does: a new live space, the old one retired, and
|
||||
# the target profile active -- one transaction.
|
||||
target = _activate(profiles, secrets, dimensions=2560, model="chat-2")
|
||||
with spaces.transaction() as connection:
|
||||
set_state_in(connection, old_space.space_id, state="retired", expected=("live",))
|
||||
insert_space_in(
|
||||
connection,
|
||||
derived_space(target).model_copy(update={"space_id": "new-space"}),
|
||||
)
|
||||
|
||||
async def read() -> str:
|
||||
return str((await handle.client()).collection)
|
||||
|
||||
Generated
+4
-4
@@ -991,7 +991,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hy-memory"
|
||||
version = "2.0.9"
|
||||
version = "2.0.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
@@ -1008,9 +1008,9 @@ dependencies = [
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/28/e01fd9a92cd090c8236df0dd06dae049c5de66345e5bd9653497ed56125e/hy_memory-2.0.9.tar.gz", hash = "sha256:5f55ea92044ced19a00b4e56c565215adedd3439a1bdb9cfe9ffe42387c8f1a1", size = 858323, upload-time = "2026-11-01T00:29:49.914Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/ed/1e1264d5b8741237c34fdaf17ae68bf5d16d403ca73c054997a502cd06a3/hy_memory-2.0.10.tar.gz", hash = "sha256:0841a441862e2427f75898bb8c958ed22fa083d2c0326298687ee129bcb729a8", size = 881659, upload-time = "2026-11-01T11:33:33.742Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/44/4368555a408a58daf902c81b62184a3c99f12abd45d4f1144bf79f171a6f/hy_memory-2.0.9-py3-none-any.whl", hash = "sha256:95dbea232d2041f9b0ea3d08dd751603c2a5c0e4422b97e74f664e6ca913549e", size = 923250, upload-time = "2026-11-01T00:29:47.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d6/c07a9d2d3af14da5a90c2bc466a46fc77068c24570fafe8f5729e46e2cae/hy_memory-2.0.10-py3-none-any.whl", hash = "sha256:54528d61a1730c7ea0f464f15144de3d1952f5d9aab1875bc841c97a193914b1", size = 947165, upload-time = "2026-11-01T11:33:31.66Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1837,7 +1837,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "filelock", specifier = ">=3.32.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "hy-memory", specifier = "==2.0.9" },
|
||||
{ name = "hy-memory", specifier = "==2.0.10" },
|
||||
{ name = "keyring", specifier = ">=25.7.0" },
|
||||
{ name = "numpy", specifier = "==2.5.2" },
|
||||
{ name = "onnxruntime", specifier = "==1.23.2" },
|
||||
|
||||
@@ -130,7 +130,7 @@ This exception does not extend to VDB, Coding, prompt, reconcile, or lifecycle.
|
||||
|
||||
- Storage backends:仅 SDK config/env(Chroma/FAISS/Qdrant、Kuzu/Neo4j、cache/history sqlite|mysql、coding sqlite 等)。
|
||||
- Queue:pending `memory_write_jobs` 只表示尚未调用 SDK 的待处理请求,因此没有 SDK raw/memory ID。worker 单次调用 SDK `add()`;SDK 内部完成 raw 写入与 extraction/reconciliation,成功后 MemHub 仅将返回的 raw ID / memory IDs 记作 job outcome。
|
||||
- Raw retention:MemHub 不删除任何 SDK L1 raw,任何模式都全部保留(SDK 自身也不删 raw);每个完整 turn 只保存真人 user 与 assistant,tool 不进入 raw。记忆模式(lite / pro / ultra)与 evolve 见 `docs/contracts/memory-mode-evolve-v1.md`。
|
||||
- Raw retention:MemHub 不删除任何 SDK L1 raw,任何模式都全部保留(SDK 自身也不删 raw);每个完整 turn 只保存真人 user 与 assistant,tool 不进入 raw。记忆模式(lite / pro / ultra)与 evolve 见 `docs/contracts/memory-mode-evolve-v1.md`;SDK 可选检索与提取功能(graph rerank、episode recall 等,全局设置,默认保持 SDK 默认值)见 `docs/contracts/memory-features-v1.md`。
|
||||
- Recall:Host Bridge / Core 先做宿主安装凭据与 tool grant,再调 **SDK search/get**。当前产品没有逐 memory 的 A-harness→B-harness 可见性开关,默认允许跨 harness 搜索;未来若增加来源限制,直接透传 SDK `source` / `include_sources` / `exclude_sources`,不在 MemHub 建 memory ACL。
|
||||
- Context wire:cutover marker 提交前只使用 legacy Context v1;提交后 Host Bridge 只使用 SDK-native Context v2。两种 schema 原样校验、原样返回,不把 SDK item 补造成 v1 的 `revision`、`evidence_ids`、`paths` 或 `adoption`,已选择的 v2 路径失败时也不回退 legacy 权威。
|
||||
- Ask wire:marker 前保留 Governance Ask v1;marker 后 Web / Host Bridge 只调用 SDK Ask v2。v2 的引用只含 SDK 原生 `memory_id/content/layer/status/source/gmt_created`,生成过程中重复 `get` 校验同一 SDK 快照,不补造 revision、Evidence 或 scopes,SDK 失败不回退 v1。
|
||||
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
|
||||
- Linux x64/arm64 Runtime on native GHA runners.
|
||||
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
|
||||
- Windows is not supported.
|
||||
- `hy-memory==2.0.9` is the current stable SDK dependency; the release
|
||||
- `hy-memory==2.0.10` is the current stable SDK dependency; the release
|
||||
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
|
||||
|
||||
See [`INSTALLATION.md`](INSTALLATION.md).
|
||||
|
||||
@@ -166,6 +166,24 @@ node apps/cli/dist/one-memory.cjs evolve --user-id wife --wait # 立即进化
|
||||
- `status --json` 与 `progress --json` 输出 `pipeline.mode`、每晚进化时间和每个用户的上次进化。
|
||||
- 契约见 `docs/contracts/memory-mode-evolve-v1.md`。
|
||||
|
||||
### SDK 可选功能(`settings features`)
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs settings features # 列出全部功能、默认值、当前模式下是否生效
|
||||
node apps/cli/dist/one-memory.cjs settings features set episode_recall=true session_episodes=true # 只显示计划
|
||||
node apps/cli/dist/one-memory.cjs settings features set episode_recall=true session_episodes=true --yes # 生效
|
||||
node apps/cli/dist/one-memory.cjs settings features reset episode_recall # 恢复默认
|
||||
node apps/cli/dist/one-memory.cjs settings features reset # 全部恢复默认
|
||||
```
|
||||
|
||||
- 全局设置,不分用户;没设置的功能保持 SDK 默认值,什么都不设时与以前完全一样(App 不变)。
|
||||
- 可设:`graph_rerank`(图谱重排,仅 ultra)、`same_series_timeline`(需 graph_rerank)、`episode_recall`(片段召回,pro 即可)、`session_episodes`(片段写入)、`episode_summary`(片段摘要,每个片段一次模型调用)、`intention`(默认开)、`few_shot`。
|
||||
- 片段召回只对带片段的记忆有用:同时开 `session_episodes`,之后提取的记忆才按会话归入片段。
|
||||
- 在当前模式下不生效的功能照样保存,并注明原因(如 `graph_rerank` 在 pro 下「需要 ultra」)。
|
||||
- 修改会重建 SDK 客户端:正在写的一批按旧设置写完,之后的写入和检索用新设置;不迁移数据,不改集合名。
|
||||
- `status --json` / `progress --json` 的 `pipeline.features` 列出与默认值不同的功能。
|
||||
- 契约见 `docs/contracts/memory-features-v1.md`。
|
||||
|
||||
## 8. 召回(recall)
|
||||
|
||||
`recall` 两条路径(raw / memory)此前已真机验证。文本输出会标明每条是「原始对话摘录」
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# Embedding Switch v1
|
||||
|
||||
Updated: 2026-11-01.
|
||||
|
||||
This contract is what the App and the CLI build on to change the embedding
|
||||
model without losing a memory, and to fold in vector data an earlier build left
|
||||
in another embedding space. Core owns every decision; clients plan, confirm,
|
||||
start, follow and cancel. Core implementation: `embedding_switch.py`,
|
||||
`embedding_switch_routes.py`, `embedding_spaces.py`.
|
||||
|
||||
## 1. Rules
|
||||
|
||||
1. **Identity is model name + dimension.** Two profiles with the same
|
||||
embedding model and the same width share an embedding space, whatever the
|
||||
provider or endpoint. Changing only the endpoint, provider, key or anything
|
||||
about generation never re-embeds.
|
||||
2. **Activation never changes the space by itself.** A profile whose embedding
|
||||
identity differs from the live space's needs a *switch*:
|
||||
`POST /api/v1/model-profiles/{id}/activate` answers
|
||||
`409 embedding_switch_required` with the plan. Exception: a home with no
|
||||
vector at all (no collection holds a record, no graph, no write in flight)
|
||||
activates directly; nothing has to be carried over.
|
||||
3. **A switch re-embeds every vector of every user** (all layers, raw included,
|
||||
coding keys, working facts, entities; and in ultra mode the graph) from the
|
||||
stored text into a new space, verifies it, then commits in one SQLite
|
||||
transaction: the new space live, the old one retired, the target profile
|
||||
active. Nothing is renamed at commit.
|
||||
4. **Reads keep serving the old space until the commit.** Recall, library,
|
||||
search and context results do not change while a switch runs.
|
||||
5. **Model writes pause; intake keeps queueing.** From the moment a job exists
|
||||
until it is `done` or `rolled_back`, no conversation is turned into memory
|
||||
and direct edits/deletes answer `409 embedding_switch_in_progress`. Imports,
|
||||
live sync, manual adds and `/api/v2/memory-writes` still queue; the queue
|
||||
sediments into the new space after the commit.
|
||||
6. **Failure or cancel keeps the old model.** The source space is never
|
||||
written. A failed or cancelled job drops what it built and ends
|
||||
`rolled_back`; the old profile stays active.
|
||||
7. **Startup scan and merge.** On every Core start, before model writes resume,
|
||||
Core scans the vector store. A non-empty space that is neither live nor
|
||||
known to the registry (*stray*: an earlier build wrote one collection and
|
||||
read another) is merged: its ids missing from the live space are
|
||||
re-embedded into the live space with the live embedder. The live space's
|
||||
records win. A merged stray is retired. A stray with nothing missing is
|
||||
retired at once, without a job. The same fold-in runs inside every switch.
|
||||
8. **Retired spaces are kept 14 days**, then dropped by a daily sweep, or
|
||||
earlier with `DELETE /api/v1/embedding-spaces/{id}`. A retired space is
|
||||
never folded back in: its records are already live, and folding it in again
|
||||
would bring back memories deleted since.
|
||||
9. **Restarts resume.** A job is persisted with a heartbeat. Quitting the App,
|
||||
a crash, a reboot or an App update installing a new build mid-switch: the
|
||||
next serving Core resumes the job from its state. The upgrade migration
|
||||
(`sdk-migrate`) never touches the switch tables; `sdk-migration-status`
|
||||
reports an open job as `embedding_switch` and does not block on it.
|
||||
10. **Graph.** Any graph the live space holds is rebuilt at the target width,
|
||||
whatever the mode, so a pro home keeps the graph of its ultra days. In ultra
|
||||
every SDK client is closed while the graph is copied and again while it is
|
||||
verified, so reads wait for those two steps; the other modes keep reading.
|
||||
|
||||
## 2. Space registry
|
||||
|
||||
`GET /api/v1/embedding-spaces` lists `spaces[]` (registry rows) and
|
||||
`scanned[]` (what the vector store holds).
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `space_id` | uuid |
|
||||
| `state` | `live` · `building` (a switch target) · `stray` (found by the scan, waiting to be merged) · `retired` · `dropped` |
|
||||
| `model`, `dims` | embedding identity; `model` is null for a stray, `dims` null until measured |
|
||||
| `name_dims` | the width the collection names carry; null for an "auto" space (old name kept) |
|
||||
| `collection_base` | physical base; existing homes keep their pre-registry name byte for byte |
|
||||
| `graph_dir` | graph directory of the space (`graph` for the first) |
|
||||
| `vectors` | count at the last scan (display only) |
|
||||
| `live_since`, `retired_at`, `keep_until`, `dropped_at` | ISO 8601 timestamps; `keep_until = retired_at + 14 days` |
|
||||
|
||||
`scanned[]` entries: `collection_base`, `name_dims`, `width`, `vectors`,
|
||||
`counts` (per role), `space_id` and `state` (`unregistered` when no row names
|
||||
it). `scanned` is null with `scan_error` set when the scan could not run (for
|
||||
example `sdk_reembed_unavailable` on an SDK older than 2.0.10).
|
||||
|
||||
## 3. Endpoints
|
||||
|
||||
All routes need the local API token (or a valid UI session). Errors keep
|
||||
Core's shape `{"detail": "<code>"}`; a refusal that computed a plan adds
|
||||
`"plan": {...}`.
|
||||
|
||||
| Method and path | Request | Success | Purpose |
|
||||
|---|---|---|---|
|
||||
| `POST /api/v1/embedding-switches/plan` | `{profile_id, expected_revision?}` | 200 plan | No side effects. |
|
||||
| `POST /api/v1/embedding-switches` | `{profile_id, expected_revision, plan_token, requested_by: "app"\|"cli"}` | 202 job | Start a switch. A stale `plan_token` is refused. |
|
||||
| `GET /api/v1/embedding-switches/current` | — | 200 current | Poll while a dialog, banner or loading screen shows it (1 s). `Cache-Control: no-store`. |
|
||||
| `GET /api/v1/embedding-switches/{job_id}` | — | 200 job | One job. |
|
||||
| `POST /api/v1/embedding-switches/{job_id}/cancel` | — | 200 job | Sets `cancel_requested`; honoured at the next batch. |
|
||||
| `GET /api/v1/embedding-spaces` | — | 200 | Registry + scan. |
|
||||
| `DELETE /api/v1/embedding-spaces/{space_id}` | — | 200 `{space}` | Drop a **retired** space now. |
|
||||
| `POST /api/v1/model-profiles/{id}/activate` | — | 200 profile | **Changed**: see §1 rule 2. |
|
||||
| `GET /api/v1/memory-processing`, `GET /api/v1/health` | — | — | New field `embedding_switch` (§4.1). |
|
||||
|
||||
### 3.1 Plan
|
||||
|
||||
```json
|
||||
{
|
||||
"needed": true,
|
||||
"reason": "embedding_identity_changed | same_embedding | empty_home",
|
||||
"plan_token": "…",
|
||||
"source": {"space_id": "…", "model": "bge-small-zh-v1.5", "dims": 512, "collection_base": "memhub_…", "vectors": 12480},
|
||||
"target": {"space_id": null, "model": "text-embedding-v3", "dims": 1024, "collection_base": ""},
|
||||
"target_profile_id": "…", "target_revision": 5,
|
||||
"target_provider_kind": "openai_compatible", "target_local": false,
|
||||
"sources": [{"space_id": "…", "collection_base": "…", "dims": 512, "vectors": 12480, "unmerged": null}],
|
||||
"total_vectors": 12480, "users": 2, "graph_vectors": null,
|
||||
"estimate_s": 63, "requests": 195,
|
||||
"disk_bytes_needed": 73400320, "disk_bytes_free": 101234567890,
|
||||
"retention_days": 14,
|
||||
"blockers": []
|
||||
}
|
||||
```
|
||||
|
||||
- `needed: false` with `same_embedding`: activate as before (generation-only
|
||||
change, or the same model and width at another endpoint).
|
||||
- `needed: false` with `empty_home`: activate; Core moves the space in the same
|
||||
transaction.
|
||||
- `sources[0]` is the live space; further entries are strays folded in
|
||||
(`unmerged` = their record count). `users` counts distinct `user_id`s.
|
||||
- `requests` is null for a local target (no cost). `estimate_s` is a rough
|
||||
guide (local ≈ 60 vectors/s, remote ≈ 200 vectors/s), not measured.
|
||||
- `graph_vectors` is null when there is no graph, and also when an SDK client
|
||||
holds it open, as in ultra: the estimate and disk figures then leave the graph
|
||||
out. The switch still rebuilds it.
|
||||
- A non-empty `blockers` means the switch cannot start (§5.1).
|
||||
|
||||
### 3.2 Job
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "…", "kind": "switch | merge",
|
||||
"state": "preparing | reembedding | verifying | committing | done | failed | rolled_back",
|
||||
"source": {…space summary…}, "target": {…space summary…},
|
||||
"stray_space_ids": [], "target_profile_id": "…", "target_revision": 5,
|
||||
"requested_by": "app | cli | startup",
|
||||
"progress": {
|
||||
"phase": "prepare | vectors | graph | verify | commit | rollback | done",
|
||||
"done": 6240, "total": 12480, "percent": 50.0,
|
||||
"embedded": 6240, "reused": 0, "kept": 0,
|
||||
"role": "memory", "source_index": 1, "sources": 1,
|
||||
"rate_per_s": 180.2, "eta_s": 35, "updated_at": "…"
|
||||
},
|
||||
"cancel_requested": false, "error_code": null, "attempt": 1,
|
||||
"created_at": "…", "updated_at": "…", "finished_at": null
|
||||
}
|
||||
```
|
||||
|
||||
| State | Meaning | Writes |
|
||||
|---|---|---|
|
||||
| `preparing` | Gate closed; waiting (≤ 10 min) for model writes already past their claim | paused |
|
||||
| `reembedding` | Vectors (then graph) re-embedded; `progress` advances | paused |
|
||||
| `verifying` | Ids, documents, metadata, widths and per-user counts compared | paused |
|
||||
| `committing` | The single SQLite transaction | paused |
|
||||
| `done` | Terminal. New space live (switch) or strays retired (merge) | resume |
|
||||
| `failed` | **Rollback in progress** (not terminal): dropping the partial target | paused |
|
||||
| `rolled_back` | Terminal. Old space and profile unchanged; `error_code` says why | resume |
|
||||
|
||||
`kind: "merge"` jobs come from the startup scan (`requested_by: "startup"`);
|
||||
their target is the live space. Cancel is accepted in `preparing`,
|
||||
`reembedding` and `verifying`.
|
||||
|
||||
### 3.3 Current
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"phase": "idle | scanning | running",
|
||||
"writes_paused": true,
|
||||
"scan_state": "pending | scanning | done | failed | unavailable",
|
||||
"scan_error": null,
|
||||
"job": {…job…} ,
|
||||
"last_finished": {…job…}
|
||||
}
|
||||
```
|
||||
|
||||
`job` is the open job or null; `last_finished` the most recent terminal job,
|
||||
so a client can show the outcome after it ends.
|
||||
|
||||
### 3.4 Probe
|
||||
|
||||
The probe result shape is unchanged. What changed is what the probe records:
|
||||
|
||||
- On the profile that fills the live space (the active one, or the one the
|
||||
space was built with), a measured width that differs from the space's width
|
||||
is **never** recorded. Recording it used to move writes to new, empty
|
||||
collections silently (B2).
|
||||
- A measured width equal to the width the space already stores is a
|
||||
*relabel*: it is recorded, and no collection name changes. An "auto" space
|
||||
whose width was never recorded takes the measured width when Chroma stores
|
||||
the same width, or when it stores nothing yet.
|
||||
- Every other profile records what it measured, as before.
|
||||
|
||||
A probe never tells the App that a switch is needed. The App learns that only
|
||||
from `POST /api/v1/embedding-switches/plan` (`needed: true`), or from the body
|
||||
of a `409 embedding_switch_required` answer to activate.
|
||||
|
||||
## 4. Summary fields
|
||||
|
||||
### 4.1 `embedding_switch`
|
||||
|
||||
On `/api/v1/memory-processing` and `/api/v1/health`:
|
||||
`{"job_id": "…", "kind": "switch|merge", "state": "<job state>", "percent": 50.0, "eta_s": 35}`,
|
||||
or `{"kind": "merge", "state": "scanning", "percent": 0.0, "eta_s": null}` during
|
||||
the startup scan, or null.
|
||||
|
||||
## 5. Error codes
|
||||
|
||||
### 5.1 Refusals
|
||||
|
||||
| Code | HTTP | When |
|
||||
|---|---|---|
|
||||
| `embedding_switch_required` | 409 (+ `plan`) | activate with another embedding on a non-empty home |
|
||||
| `embedding_switch_running` | 409 | activate / start / embedding edit of the active or target profile while a job is open |
|
||||
| `embedding_switch_plan_stale` | 409 (+ `plan`) | `plan_token` no longer matches (data or live space changed) |
|
||||
| `embedding_switch_not_needed` | 409 (+ `plan`) | start with the same identity, or on an empty home (activate instead) |
|
||||
| `embedding_switch_in_progress` | 409 | memory edit/delete, `POST /api/v1/graph-width` while writes are paused |
|
||||
| `embedding_switch_not_cancellable` | 409 | cancel in `committing` or later |
|
||||
| `embedding_switch_not_found` | 404 | unknown job, or cancel with no open job |
|
||||
| `embedding_space_not_found` | 404 | unknown or already dropped space |
|
||||
| `embedding_space_not_retired` | 409 | delete of a live, building or stray space |
|
||||
| `embedding_space_in_use` | 409 | delete of a space an SDK client still holds open; retry later |
|
||||
| `embedding_probe_required` | 409 (blocker) | target not probed at this revision (activate answers `model_profile_probe_required`) |
|
||||
| `embedding_dimensions_unknown` | 409 (blocker) | target width unknown; probe it or declare `embedding_dimensions` |
|
||||
| `embedding_switch_requires_migration` | 409 (blocker) | legacy `one_memory_*` collections still wait for `sdk-migrate` |
|
||||
| `sdk_reembed_unavailable` | 503 (blocker) | installed SDK has no re-embed module (before hy-memory 2.0.10) |
|
||||
| `insufficient_disk_space` | 507 (blocker) | free disk < 1.5 × estimated target size + 200 MB |
|
||||
| `model_profile_not_found` | 404 | unknown profile |
|
||||
| `model_profile_conflict` | 409 | `expected_revision` stale |
|
||||
| `embedding_unavailable` | 503 | search, recall, context, library search or ask could not embed the query (remote embedding down). No lexical fallback. |
|
||||
| `embedding_space_profile_missing` | 503 | the profile that fills the live space was deleted and no active profile has its model and width; activate one that does |
|
||||
|
||||
### 5.2 Job failure codes (`error_code` on a `rolled_back` job)
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `writes_did_not_drain` | in-flight model writes did not finish within 10 minutes |
|
||||
| `embedding_service_failed` | the embedding service kept failing after retries |
|
||||
| `embedding_dimensions_mismatch` | the service returned another width than probed |
|
||||
| `embedding_verification_failed` | the copy did not verify (missing/changed ids, widths, per-user counts, graph) |
|
||||
| `target_profile_changed` | the target profile was edited during the switch |
|
||||
| `embedding_switch_cancelled` | cancelled by the user |
|
||||
| `model_secret_unavailable` | the embedding API key could not be read |
|
||||
| `secure_storage_unavailable` | the system keychain was unavailable |
|
||||
| `embedding_switch_clients_busy` | SDK clients stayed busy; the graph could not be copied |
|
||||
|
||||
## 6. App guidance
|
||||
|
||||
### 6.1 Setup flow (`finishSetup`)
|
||||
|
||||
probe → `POST /embedding-switches/plan` → `needed: false`: activate as today →
|
||||
`needed: true`: show the confirmation dialog; on confirm
|
||||
`POST /embedding-switches` with the plan's `plan_token`. The active profile does
|
||||
not change until the job is `done`. Persist `embedding_base_url` explicitly
|
||||
for remote embeddings. Handle `409 embedding_switch_required` from activate
|
||||
the same way (the plan is in the body).
|
||||
|
||||
### 6.2 Loading screen (startup merge)
|
||||
|
||||
Core is already ready and serving reads when the App's loading phase starts.
|
||||
Poll `GET /api/v1/embedding-switches/current` (or `/api/v1/health` →
|
||||
`embedding_switch`) every second. While `phase` is `scanning`, or `job.kind` is
|
||||
`merge` and not terminal, show the line below with `progress.percent`. The App
|
||||
may enter the home before the merge ends (reads work); then show the banner
|
||||
(§6.4) instead.
|
||||
|
||||
| | Copy |
|
||||
|---|---|
|
||||
| zh | 正在检查向量数据… / 正在合并旧版本留下的向量数据 {p}%,完成前暂不整理新记忆。 |
|
||||
| en | Checking vector data… / Merging vector data left by an earlier version: {p}%. New memories are processed once this finishes. |
|
||||
| es | Comprobando los datos vectoriales… / Fusionando datos vectoriales de una versión anterior: {p} %. Los recuerdos nuevos se procesarán al terminar. |
|
||||
|
||||
### 6.3 Confirmation dialog
|
||||
|
||||
| | Copy |
|
||||
|---|---|
|
||||
| zh | **更换向量模型** 你把向量模型从 **{旧模型}({旧维度} 维)** 换成了 **{新模型}({新维度} 维)**。不同的向量模型无法互相检索。为了让已有记忆在新模型下仍能被找到,MemHub 会用新模型为全部记忆重新生成向量:共 **{N} 条向量**({用户数} 个用户),预计耗时 **约 {T}**({本地:在本机计算,不产生费用 / 远程:将调用 {endpoint},约 {请求数} 次请求,可能产生费用}),需要额外磁盘空间约 **{X} MB**。切换期间:新的对话会照常记录,但暂不整理成记忆,切换完成后自动补上;检索和记忆库继续使用旧模型;暂时无法编辑或删除记忆。全部完成并校验通过后才会正式切换。中途失败或取消,会保持使用旧模型,不丢失任何数据。旧的向量数据会保留 14 天。[取消] [开始切换] |
|
||||
| en | **Change embedding model** You changed the embedding model from **{old} ({oldDims}-d)** to **{new} ({newDims}-d)**. Vectors from different models cannot search each other, so MemHub will re-create the vectors of all memories with the new model: **{N} vectors** ({users} users), about **{T}** ({local: computed on this Mac, free / remote: calls {endpoint}, about {requests} requests, may cost money}), about **{X} MB** of extra disk. During the switch new conversations are still recorded but not turned into memories until it finishes; search and the library keep using the old model; memories cannot be edited or deleted. The switch takes effect only after everything is verified. If it fails or you cancel, the old model stays and no data is lost. Old vectors are kept for 14 days. [Cancel] [Start switch] |
|
||||
| es | **Cambiar el modelo de embeddings** Cambiaste el modelo de **{old} ({oldDims} d)** a **{new} ({newDims} d)**. Los vectores de modelos distintos no se pueden buscar entre sí, así que MemHub regenerará los vectores de todos los recuerdos con el nuevo modelo: **{N} vectores** ({users} usuarios), unos **{T}** ({local: se calcula en este equipo, sin coste / remoto: llama a {endpoint}, unas {requests} solicitudes, puede tener coste}), unos **{X} MB** de disco adicional. Durante el cambio las conversaciones nuevas se registran pero no se convierten en recuerdos hasta terminar; la búsqueda y la biblioteca siguen usando el modelo anterior; no se pueden editar ni borrar recuerdos. El cambio solo se aplica tras verificarlo todo. Si falla o lo cancelas, se mantiene el modelo anterior sin perder datos. Los vectores anteriores se guardan 14 días. [Cancelar] [Iniciar cambio] |
|
||||
|
||||
Extra lines: same width (`source.dims == target.dims`) — zh 新旧模型维度相同,但向量不能通用,仍需重新生成。/ en Both models have the same width, but their vectors are not interchangeable, so they still have to be re-created. / es Ambos modelos tienen la misma dimensión, pero sus vectores no son intercambiables y hay que regenerarlos. Builtin target (`target_local`) — zh 将使用内置模型 bge-small-zh-v1.5,在本机计算,不产生费用。/ en Uses the built-in model bge-small-zh-v1.5, computed on this Mac at no cost. / es Usa el modelo integrado bge-small-zh-v1.5, calculado en este equipo sin coste. Strays in `sources` — zh 另外会合并旧版本留下的 {M} 条向量。/ en Also merges {M} vectors left by an earlier version. / es También fusiona {M} vectores de una versión anterior. Blockers disable [Start] and show §5.1 text.
|
||||
|
||||
### 6.4 Progress banner and card
|
||||
|
||||
| State | zh | en | es |
|
||||
|---|---|---|---|
|
||||
| preparing | 正在准备切换向量模型,等待正在进行的记忆整理结束… | Preparing to switch the embedding model, waiting for memory processing in progress… | Preparando el cambio de modelo, esperando a que termine el procesamiento en curso… |
|
||||
| reembedding | 正在切换向量模型:重新生成向量 {done}/{total}({p}%),预计还需 {eta}。记忆整理已暂停,检索不受影响。[取消切换] | Switching embedding model: re-creating vectors {done}/{total} ({p}%), about {eta} left. Memory processing is paused; search is unaffected. [Cancel] | Cambiando el modelo: regenerando vectores {done}/{total} ({p} %), faltan unos {eta}. El procesamiento está en pausa; la búsqueda no se ve afectada. [Cancelar] |
|
||||
| verifying / committing | 正在校验新向量… | Verifying the new vectors… | Verificando los vectores nuevos… |
|
||||
| done (switch) | 已切换到 {新模型}。暂停期间的对话正在整理成记忆。[清理旧向量数据] | Switched to {new}. Conversations from the pause are being processed now. [Clean up old vectors] | Cambiado a {new}. Las conversaciones de la pausa se están procesando. [Limpiar vectores antiguos] |
|
||||
| done (merge) | 旧向量数据已合并。 | Old vector data merged. | Datos vectoriales antiguos fusionados. |
|
||||
| failed | 切换未完成,正在恢复旧向量模型… | The switch did not finish; restoring the previous model… | El cambio no terminó; restaurando el modelo anterior… |
|
||||
| rolled_back | 切换未完成,已恢复使用 {旧模型},数据未受影响。原因:{reason} [重试] [查看日志] | The switch did not finish; {old} is in use again and no data was affected. Reason: {reason} [Retry] [View log] | El cambio no terminó; se vuelve a usar {old} sin afectar a los datos. Motivo: {reason} [Reintentar] [Ver registro] |
|
||||
| rolled_back + `embedding_switch_cancelled` | 已取消切换,继续使用 {旧模型}。 | Switch cancelled; still using {old}. | Cambio cancelado; se sigue usando {old}. |
|
||||
|
||||
Retry = plan again and start a new job. "Clean up" calls
|
||||
`DELETE /api/v1/embedding-spaces/{source.space_id}` (show `vectors`).
|
||||
|
||||
`{reason}` per §5.2:
|
||||
|
||||
| Code | zh | en | es |
|
||||
|---|---|---|---|
|
||||
| writes_did_not_drain | 等待正在进行的记忆整理结束超时 | Timed out waiting for memory processing to finish | Se agotó la espera del procesamiento en curso |
|
||||
| embedding_service_failed | 向量服务连续请求失败 | The embedding service kept failing | El servicio de embeddings falló repetidamente |
|
||||
| embedding_dimensions_mismatch | 向量服务返回的维度与探测到的不一致 | The service returned another vector width than probed | El servicio devolvió otra dimensión que la detectada |
|
||||
| embedding_verification_failed | 新向量没有通过校验 | The new vectors did not verify | Los vectores nuevos no superaron la verificación |
|
||||
| target_profile_changed | 切换期间目标配置被修改 | The target profile was edited during the switch | El perfil de destino se modificó durante el cambio |
|
||||
| model_secret_unavailable | 读不到向量服务的 API Key | The embedding API key could not be read | No se pudo leer la clave API de embeddings |
|
||||
| secure_storage_unavailable | 系统钥匙串不可用 | The system keychain is unavailable | El llavero del sistema no está disponible |
|
||||
| embedding_switch_clients_busy | 记忆图谱一直被占用 | The memory graph stayed busy | El grafo de memoria siguió ocupado |
|
||||
|
||||
Blocked actions while writes are paused (`409 embedding_switch_in_progress`
|
||||
/ `embedding_switch_running`): zh 正在切换向量模型,暂时无法修改记忆,请在切换完成后再试。/ 上一次向量模型切换还在进行,请等待完成或先取消。 en The embedding model is being switched; memories can be edited again once it finishes. / A previous switch is still running; wait for it or cancel it first. es Se está cambiando el modelo de embeddings; podrás editar recuerdos al terminar. / Aún hay un cambio en curso; espera o cancélalo primero.
|
||||
|
||||
### 6.5 `embedding_unavailable` toast
|
||||
|
||||
Any search/recall/context route answering `503 {"detail": "embedding_unavailable"}`:
|
||||
show a top-right toast (do not show an empty result list).
|
||||
|
||||
| | Copy |
|
||||
|---|---|
|
||||
| zh | 向量服务暂时不可用,无法检索记忆。请检查向量模型的服务地址和 API Key。 |
|
||||
| en | The embedding service is unavailable, so memories cannot be searched. Check the embedding model's endpoint and API key. |
|
||||
| es | El servicio de embeddings no está disponible y no se pueden buscar recuerdos. Revisa la dirección del servicio y la clave API del modelo de embeddings. |
|
||||
|
||||
## 7. CLI
|
||||
|
||||
`memhub models activate <id> [--yes] [--wait|--no-wait] [--json]` shows the
|
||||
plan; without `--yes` it prompts on a terminal and exits 2 otherwise; with
|
||||
`--yes` it starts the switch and follows it (default on a terminal; `--json`
|
||||
emits one JSON line per progress change, then a result line). Ctrl-C only
|
||||
stops watching. `memhub models add … --activate` probes first, then the same
|
||||
path. `memhub models switch status|cancel`, `memhub models spaces
|
||||
[--cleanup <space-id> --yes]`, and a `向量模型:…` line in `memhub status`.
|
||||
@@ -0,0 +1,226 @@
|
||||
# SDK retrieval and extraction features v1
|
||||
|
||||
Status: Core + CLI implemented. Stacks on the memory-mode contract
|
||||
(`memory-mode-evolve-v1.md`). The App keeps today's behaviour and has no
|
||||
control for this yet; the copy in section 9 is there if it adds one.
|
||||
|
||||
MemHub hard-codes none of the SDK's optional features. A person can turn the
|
||||
ones below on or off through the CLI or the API. Nobody has to: an unset
|
||||
feature keeps the SDK default, and with nothing set the SDK config is byte for
|
||||
byte what MemHub built before this setting existed.
|
||||
|
||||
All routes need the Runtime bearer token. Unknown query parameters and body
|
||||
fields answer `422 {"detail": "request_validation_failed"}`, like every other
|
||||
settings route. The setting is global, like the memory mode: it applies to
|
||||
every user's SDK client and takes no `user_id`.
|
||||
|
||||
## 1. Inventory
|
||||
|
||||
These are the hy-memory flags (`hy_memory/config.py` and the modules that read
|
||||
the environment directly) that change retrieval or extraction quality. Cost:
|
||||
"none" means no extra call; "query" means one more local store query per
|
||||
search; "LLM" means extra generation-model calls; "tokens" means a longer
|
||||
prompt and no extra call.
|
||||
|
||||
### Exposed
|
||||
|
||||
| Name | SDK field (env) | Default | Mode | Cost |
|
||||
|---|---|---|---|---|
|
||||
| `graph_rerank` | `graph_store.graph_rerank_enabled` (`MEMORY_GRAPH_RERANK_ENABLED`) | off | ultra (graph store) | query (graph edges) |
|
||||
| `same_series_timeline` | `recall.same_series_timeline_enabled` (`MEMORY_SAME_SERIES_TIMELINE_ENABLED`) | off | ultra + `graph_rerank` | none |
|
||||
| `episode_recall` | `recall.episode_recall_enabled` (`MEMORY_EPISODE_RECALL_ENABLED`) | off | pro or ultra; no graph | query (episode members) |
|
||||
| `session_episodes` | `recall.session_episode_enabled` (`MEMORY_SESSION_EPISODE_ENABLED`) | off | pro or ultra | none |
|
||||
| `episode_summary` | `recall.episode_summary_enabled` (`MEMORY_EPISODE_SUMMARY_ENABLED`) | off | pro or ultra | LLM: one call per closed episode of 3+ memories |
|
||||
| `intention` | `llm.enable_intention` (`MEMORY_ENABLE_INTENTION`) | **on** | pro or ultra | none (off shortens the prompt) |
|
||||
| `few_shot` | `llm.few_shot_enabled` (`MEMORY_FEW_SHOT_ENABLED`) | off | pro or ultra | tokens (extraction and reconcile) |
|
||||
|
||||
- **Graph rerank (usage A).** It propagates support over the graph edges
|
||||
inside the recall pool (`reader_base._apply_graph_rerank`). It needs a graph
|
||||
store, which MemHub opens only in ultra. In pro and lite it is reported
|
||||
`active: false, reason: "requires_ultra"`.
|
||||
- **Episode recall (usage B).** It adds memories from the same episode as a
|
||||
hit and scores them on three channels: tag-keyword overlap, content overlap
|
||||
and temporal affinity. It needs no graph, so it works in pro (issue #121).
|
||||
It helps only memories that carry an `episode_id`.
|
||||
- Memories that System 1 adds through reconcile get an idle-gap
|
||||
`episode_id` whenever the SDK cache is available, which MemHub's SQLite
|
||||
cache is. A new episode starts after 30 minutes idle or 50 memories.
|
||||
- `session_episodes` replaces that with one stable episode per conversation.
|
||||
- Memories stored before the change keep the id they had.
|
||||
- Raw turns from lite, and notes written before episodes existed, carry none.
|
||||
- **Episode write side.** `session_episodes` and `episode_summary` are the
|
||||
write half of episodes, and both are off by default. Usage B only helps once
|
||||
episodes are being written. The SDK's session narrative (L3, `finalize_session`)
|
||||
is a separate feature and is not exposed (section 2).
|
||||
- **`intention`** is the only feature that is on by default. Turning it off
|
||||
stops L7 intention memories from being extracted.
|
||||
|
||||
### Left out, and why
|
||||
|
||||
| Flag(s) | Why it is not exposed |
|
||||
|---|---|
|
||||
| `recall.entity_store_enabled` (`MEMORY_ENTITY_STORE_ENABLED`) | Entity extraction needs spaCy and `en_core_web_sm`, which MemHub does not ship, so it would silently do nothing. It also writes a new `{collection}_entities` collection, and MemHub's collection identity has to stay fixed. |
|
||||
| `llm.enable_query_rewrite` (`MEMORY_ENABLE_QUERY_REWRITE`) | It only affects default-mode searches that pass `queries=[...]`, and MemHub never sends that. Per-request rewrite already exists as `search_mode: "agentic"` (#252). |
|
||||
| `session_episode_context_enabled`, `dynamic_episode_enabled`, `series_episode_enabled`, `episode_capsule_context_enabled`, `episode_member_expansion_enabled` | They read or write the L3 session narrative that `finalize_session` builds, and MemHub never calls it. When dynamic or series finalize fails, the SDK fails the write with a non-retryable 409 partial result, which would leave MemHub jobs `unknown`. |
|
||||
| `local_rerank_enabled` (+ model / device) | It downloads a cross-encoder model on first use. That is a network fetch MemHub's packaged, offline runtime and zero-collection rules do not allow. |
|
||||
| `strength_enabled`, `access_tracking_enabled` | They write access counts back to memory rows on every search: a read that mutates. Internal. |
|
||||
| `structure_metadata_enabled`, `retrieval_clauses_*`, `supplemental_propositions_enabled`, `hierarchy_bundle_*`, `multi_seed_bundle_*`, `tag_index_nominee_*`, `dynamic_entity_tag_embed_enabled` | Experimental retrieval lanes, each with many tuning parameters. The SDK marks them as still under gray validation, and some depend on System 2 output. Internal tuning, not a person's choice. |
|
||||
| `graph_store.memory_graph_write`, `path_enabled` (default on) | Turning them off breaks graph rerank and evolve. Dangerous. |
|
||||
| `hierarchy_topic_write`, `temporal_spine`, `topic_summary_enabled` | Experimental graph write lanes ("stage 5"); topic summary also costs LLM calls. Not validated for users. |
|
||||
| Schema lane and catalog / supplement (`MEMORY_EVOLING_SCHEMA_DEDUP_ENABLED`, `MEMORY_EVOLING_SCHEMA_COMPACTION_ENABLED`, `MEMORY_EVOLING_BALANCED_CATALOG_ENABLED`, `MEMORY_EVOLING_SPECIFIC_SUPPLEMENT_ENABLED`, `MEMORY_USER_PROFILE_*`, `MEMORY_HYBRID_V2_*_L0_ENABLED`) | The SDK reads them from `os.environ`, not from `MemoryConfig`. Some are read once at import (`system2_tools`), others on each call for the whole process. They cannot be scoped to one client, and setting them in Core's environment would need a restart and would leak into every client. Section 3. |
|
||||
| The hybrid reader's env knobs (`MEMORY_HYBRID_V2_*`, `MEMORY_TOPIC_*_ENABLED`, `MEMORY_*_BUNDLE_ENABLED`, `MEMORY_RERANK_*`) | Same reason: read from the process environment on each search. |
|
||||
| `RECONCILE_ENABLED`, `SWEEPER_ENABLED`, `MEMORY_CROSS_DOMAIN_ENABLE` | Read at import time. Turning reconcile off stores every extraction without dedup. Dangerous. |
|
||||
| `coding.chain_block_enabled`, `coding.verbatim_fallback_enabled` | Coding-scene options. The verbatim fallback stores assistant and tool traces as facts. |
|
||||
| `MEMORY_ENABLE_GRAPH` (graph in pro) | That is the memory mode's job (`settings mode`). |
|
||||
| `MEMORY_LOG_LLM_CONTENT`, pipeline log, trace, memory operations, metrics / telemetry | Diagnostics. MemHub controls them elsewhere (`diagnostic-logging.md`). |
|
||||
|
||||
## 2. The setting
|
||||
|
||||
`config.json` gains an optional `memory_features` object next to
|
||||
`memory_mode`. It is written only once a person sets a feature, and then it
|
||||
holds only the features they set:
|
||||
|
||||
```json
|
||||
{"memory_mode": "pro", "memory_features": {"episode_recall": true, "session_episodes": true}}
|
||||
```
|
||||
|
||||
The values are strict booleans and unknown keys are refused, so a hand-edited
|
||||
file with an unknown feature fails to load (`config_invalid`), like any other
|
||||
malformed setting.
|
||||
|
||||
## 3. How a setting reaches the SDK
|
||||
|
||||
- Every exposed feature is a `MemoryConfig` field that the SDK reads from the
|
||||
config object when it is called. None of them is read from `os.environ` at
|
||||
call time.
|
||||
- `hy_sdk_runtime.build_sdk_config(..., features=...)` sets those fields on
|
||||
each client's config after `MemoryConfig.from_dict`. The result is
|
||||
deterministic per client.
|
||||
- The SDK's own env vars (the second column of the table) still decide the
|
||||
default of a feature nobody set. That is exactly what happened before the
|
||||
setting existed. A setting always wins over the env var.
|
||||
- MemHub writes no env var. The flags the SDK reads straight from the
|
||||
environment are the ones left out above.
|
||||
- Each client's features are fixed when it is built, like its mode. Both the
|
||||
write handles (`SdkWriteCoordinator`) and the read handle
|
||||
(`ActiveProfileReadHandle`) are keyed by (profile, revision, mode,
|
||||
features).
|
||||
- A change runs `retire_stale_handles`, the #218 use count. An idle write
|
||||
handle closes at once, and a write in flight finishes with the features it
|
||||
started with. The next read builds a client with the new key. The old read
|
||||
client closes once no request still uses it.
|
||||
- Nothing stored is migrated. A feature never touches `vector_store`, the
|
||||
embedder or any store path, so the collection identity is the same with any
|
||||
features (tested).
|
||||
|
||||
## 4. `GET /api/v1/settings/memory-features`
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "pro",
|
||||
"features": [
|
||||
{
|
||||
"name": "graph_rerank",
|
||||
"enabled": true,
|
||||
"configured": true,
|
||||
"default": false,
|
||||
"active": false,
|
||||
"reason": "requires_ultra",
|
||||
"side": "read",
|
||||
"requires_mode": "ultra",
|
||||
"requires_feature": null,
|
||||
"cost": "extra_query",
|
||||
"sdk_field": "graph_store.graph_rerank_enabled",
|
||||
"env": "MEMORY_GRAPH_RERANK_ENABLED",
|
||||
"description": "Usage A: propagate support over knowledge-graph edges ..."
|
||||
}
|
||||
],
|
||||
"non_default": {"graph_rerank": true}
|
||||
}
|
||||
```
|
||||
|
||||
- `enabled` is what clients are built with: `configured` if set, else `default`.
|
||||
- `configured` is null when unset.
|
||||
- `default` is the SDK default: its code default, or its own env var when set.
|
||||
- `active` is false with a `reason` when the feature does nothing right now.
|
||||
The reason is one of `disabled`, `requires_pro` (lite), `requires_ultra`, or
|
||||
`requires_graph_rerank` (for `same_series_timeline`).
|
||||
- `cost` is one of `none`, `extra_query`, `llm_call`, `prompt_tokens`.
|
||||
- `features` always lists every feature, in the table's order.
|
||||
|
||||
## 5. `PUT /api/v1/settings/memory-features`
|
||||
|
||||
The body has any of the feature names as keys:
|
||||
|
||||
- `true` or `false` sets that feature.
|
||||
- `null` resets it to the default.
|
||||
- An absent key is left as it is.
|
||||
- Anything else answers `422`, including a non-boolean, `"true"`, `1`, or an
|
||||
unknown name.
|
||||
|
||||
```json
|
||||
{"graph_rerank": true, "episode_recall": true, "few_shot": null}
|
||||
```
|
||||
|
||||
The answer is the state of section 4, plus `changed`: the features whose
|
||||
effective value changed, in the table's order. When the set of configured
|
||||
features changes, the SDK clients are rebuilt (section 3). That includes a
|
||||
reset to a value equal to the default, after which the client carries no
|
||||
override at all.
|
||||
|
||||
## 6. Status
|
||||
|
||||
`pipeline` in `GET /api/v1/memory-processing` (memory-mode contract §7) gains
|
||||
`features`, which lists the features whose effective value differs from the
|
||||
default. It is `{}` while every feature is at its default:
|
||||
|
||||
```json
|
||||
{"pipeline": {"mode": "pro", "...": "...", "features": {"episode_recall": true, "intention": false}}}
|
||||
```
|
||||
|
||||
`memhub status --json` and `memhub progress --json` carry it as
|
||||
`pipeline.features`. The text status appends `;SDK 功能:episode_recall=开、…`
|
||||
to the memory-mode line.
|
||||
|
||||
## 7. CLI
|
||||
|
||||
```bash
|
||||
memhub settings features # list them
|
||||
memhub settings features set episode_recall=true session_episodes=true # plan only
|
||||
memhub settings features set episode_recall=true session_episodes=true --yes
|
||||
memhub settings features reset few_shot # back to the default
|
||||
memhub settings features reset # every set feature
|
||||
```
|
||||
|
||||
`set` prints its plan and needs `--yes`, like `settings mode`, because some
|
||||
features spend model calls. `reset` only returns features to their defaults,
|
||||
so it applies at once. Unknown names or values other than `true` / `false` are
|
||||
a usage error (exit 2) before the Runtime is asked.
|
||||
|
||||
## 8. Rules kept
|
||||
|
||||
- Global management, as for the mode: no `user_id`; reads stay scoped per
|
||||
user as before.
|
||||
- Strict parameters: unknown body fields and query parameters answer 422.
|
||||
- The mode semantics of #253 are unchanged. A feature needing a higher mode is
|
||||
stored and reported inactive; it is not refused.
|
||||
|
||||
## 9. Copy the UI can reuse
|
||||
|
||||
| Key | zh-CN | en | es |
|
||||
|---|---|---|---|
|
||||
| title | 高级检索与提取 | Advanced retrieval and extraction | Recuperación y extracción avanzadas |
|
||||
| graph_rerank | 图谱重排:沿知识图谱的关联提升相关记忆的排名(仅 ultra)。 | Graph rerank: ranks memories higher when the knowledge graph links them to strong matches (ultra only). | Reordenación por grafo: sube en la lista las memorias que el grafo de conocimiento relaciona con buenas coincidencias (solo ultra). |
|
||||
| same_series_timeline | 同系列时间线:同一系列的记忆按时间放在一起(需图谱重排)。 | Same-series timeline: keeps memories of one series together in time order (needs graph rerank). | Línea temporal de la serie: mantiene juntas y en orden temporal las memorias de una misma serie (requiere la reordenación por grafo). |
|
||||
| episode_recall | 片段召回:补入与命中记忆同一片段的记忆,并按标签、内容和时间打分。 | Episode recall: adds memories from the same episode as a match and scores them by tags, content and time. | Recuperación por episodio: añade memorias del mismo episodio que una coincidencia y las puntúa por etiquetas, contenido y tiempo. |
|
||||
| session_episodes | 片段写入:同一会话中提取的记忆归入同一片段。 | Episodes per conversation: memories extracted from one conversation share one episode. | Episodios por conversación: las memorias extraídas de una conversación comparten un episodio. |
|
||||
| episode_summary | 片段摘要:片段结束时总结一次(每个片段一次模型调用)。 | Episode summaries: each finished episode is summarized once (one model call per episode). | Resúmenes de episodio: cada episodio terminado se resume una vez (una llamada al modelo por episodio). |
|
||||
| intention | 意图提取:随事实一起记住你提到的计划和意图。 | Intentions: remembers the plans and intentions you mention, along with facts. | Intenciones: recuerda los planes e intenciones que mencionas, junto con los hechos. |
|
||||
| few_shot | 提取示例:在提取提示中加入示例,可帮助较弱的模型(输入更长)。 | Extraction examples: adds examples to the extraction prompt, which can help a weaker model (longer input). | Ejemplos de extracción: añade ejemplos a la instrucción de extracción, lo que puede ayudar a un modelo más débil (entrada más larga). |
|
||||
| default | 默认 | Default | Predeterminado |
|
||||
| requires_ultra | 当前模式下不生效,需要 ultra。 | Has no effect in this mode; needs ultra. | No tiene efecto en este modo; requiere ultra. |
|
||||
| requires_pro | lite 模式下不生效。 | Has no effect in lite. | No tiene efecto en lite. |
|
||||
| requires_graph_rerank | 需要先开启图谱重排。 | Turn on graph rerank first. | Activa primero la reordenación por grafo. |
|
||||
| episodes_hint | 片段召回只对带片段的记忆有用;开启片段写入后,新提取的记忆才会按会话归入片段。 | Episode recall only helps memories that belong to an episode; with episodes per conversation on, new memories are grouped by conversation. | La recuperación por episodio solo ayuda a las memorias que pertenecen a un episodio; con los episodios por conversación activados, las memorias nuevas se agrupan por conversación. |
|
||||
| cost_llm | 会增加模型调用 | Uses extra model calls | Usa llamadas adicionales al modelo |
|
||||
| applied | 正在进行的写入按原设置完成,之后的写入和检索使用新设置。 | A write in progress finishes with the old settings; later writes and searches use the new ones. | Una escritura en curso termina con la configuración anterior; las siguientes escrituras y búsquedas usan la nueva. |
|
||||
@@ -285,11 +285,15 @@ A user is kept when `memory_stats` reports any memory or fact for them.
|
||||
"forced_by_env": false,
|
||||
"evolve": {"...": "the overview of section 5"},
|
||||
"last_evolve_at": "2026-11-01T03:00:00Z",
|
||||
"graph_build": {"...": "as in section 3"}
|
||||
"graph_build": {"...": "as in section 3"},
|
||||
"features": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`features` lists the SDK features whose value differs from the default (empty
|
||||
while every feature is at its default); see `memory-features-v1.md`.
|
||||
|
||||
`memhub status --json` adds a top-level projection of it:
|
||||
|
||||
```json
|
||||
@@ -304,7 +308,8 @@ A user is kept when `memory_stats` reports any memory or fact for them.
|
||||
"evolve_users": {
|
||||
"local": {"last_run_at": "...", "last_error": null, "state": "succeeded"}
|
||||
},
|
||||
"graph_build": "succeeded"
|
||||
"graph_build": "succeeded",
|
||||
"features": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -319,8 +324,14 @@ memhub settings mode # show the current mode
|
||||
memhub settings mode lite # print the plan, change nothing
|
||||
memhub settings mode lite --yes # switch
|
||||
memhub evolve [--user-id <id>] [--agent-id <id>] [--max-batches <n>] [--wait] [--json]
|
||||
memhub settings features [set <name>=<true|false> ... [--yes] | reset [<name> ...]]
|
||||
```
|
||||
|
||||
The SDK's optional retrieval and extraction features (graph rerank, episode
|
||||
recall, the episode write side, …) are a second global setting beside the
|
||||
mode. A change rebuilds the SDK handles the same way. Contract:
|
||||
`memory-features-v1.md`.
|
||||
|
||||
## 9. Copy the UI can reuse
|
||||
|
||||
| Key | zh-CN | en | es |
|
||||
|
||||
@@ -66,6 +66,13 @@ one-memory sdk-migration-status --json # read-only: is there work?
|
||||
one-memory sdk-migrate --runtime-build <build> --yes --json
|
||||
```
|
||||
|
||||
An unfinished embedding switch or merge (`embedding-switch-v1.md`) never makes
|
||||
a migration required and never blocks one: `sdk-migration-status` reports it as
|
||||
an extra `embedding_switch: {job_id, kind, state}` field, `sdk-migrate` leaves
|
||||
the `embedding_spaces` and `embedding_switch_jobs` tables alone, its readback
|
||||
opens the registry's live space (which a switch changes only at its commit),
|
||||
and the next serving Core resumes the job.
|
||||
|
||||
`sdk-migrate` requires a stopped Runtime. It takes the host capture fence
|
||||
(hooks return silently instead of writing Evidence while its PID is alive),
|
||||
then:
|
||||
|
||||
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
|
||||
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
|
||||
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
|
||||
HY_MEMORY_NAME = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.9"
|
||||
HY_MEMORY_VERSION = "2.0.10"
|
||||
|
||||
|
||||
class SbomError(RuntimeError):
|
||||
|
||||
@@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(readme, /`hy-memory==2\.0\.9`/);
|
||||
assert.match(readme, /`hy-memory==2\.0\.10`/);
|
||||
assert.match(readme, /`v1\.0\.5` 的发行目标是 Linux x64\/arm64 Runtime/);
|
||||
assert.match(readme, /Linux 制品由 GitHub Actions/);
|
||||
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
|
||||
|
||||
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
|
||||
dist_info = tmp_path / "hy_memory-2.0.9.dist-info"
|
||||
dist_info = tmp_path / "hy_memory-2.0.10.dist-info"
|
||||
licenses = dist_info / "licenses"
|
||||
licenses.mkdir(parents=True)
|
||||
(dist_info / "METADATA").write_text(
|
||||
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
(
|
||||
"Metadata-Version: 2.4",
|
||||
"Name: hy-memory",
|
||||
"Version: 2.0.9",
|
||||
"Version: 2.0.10",
|
||||
"License-Expression: Apache-2.0",
|
||||
"",
|
||||
)
|
||||
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
notice = module.hy_memory_notice(dist_info)
|
||||
|
||||
assert notice["name"] == "hy-memory"
|
||||
assert notice["version"] == "2.0.9"
|
||||
assert notice["version"] == "2.0.10"
|
||||
assert notice["license"] == "Apache-2.0"
|
||||
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
|
||||
assert "Licensed under the Apache License" in notice["text"]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hy-memory"
|
||||
version = "2.0.9"
|
||||
version = "2.0.10"
|
||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
raw 在写入时就裁剪好;历史拼接不再二次裁剪。
|
||||
|
||||
规则(hy_memory/utils/raw_trim.py):
|
||||
- user / system 完整保留
|
||||
- assistant 与对方真人(非已知 role 的 label)strip 后超过 500 字符截成前 500 + "..."
|
||||
- tool 头尾保留、中段替换为 "[...N bytes omitted by truncate...]"
|
||||
(上限 coding.tool_result_max_bytes)
|
||||
- 全部幂等
|
||||
|
||||
覆盖:
|
||||
1. 规则本身 + 幂等(含 coding preproc 的两个截断函数)
|
||||
2. lite:L1_RAW 存的是裁剪后的文本(tool 头尾截断 + 标记),带 raw_trimmed 标记;
|
||||
审计 history 的 ADD 记录同样裁剪
|
||||
3. coding_trace 存的 tool result 已头尾截断
|
||||
4. pro:extractor 输入仍是完整 messages;历史 = 已存 raw 直接拼接;
|
||||
没有标记的旧 raw 仍按同一规则兼容裁剪
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import types
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.coding.preproc import (
|
||||
truncate_assistant_text,
|
||||
truncate_tool_result_text,
|
||||
)
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryNode, MemoryStatus
|
||||
from hy_memory.pipelines.base import ChatMessage, WriteRequest
|
||||
from hy_memory.utils.raw_trim import (
|
||||
DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
RAW_TRIMMED_KEY,
|
||||
trim_raw_body,
|
||||
trim_raw_messages,
|
||||
)
|
||||
|
||||
_MARKER = "bytes omitted by truncate...]"
|
||||
_LONG_USER = "我把整段日志贴给你:" + "U" * 5000
|
||||
_LONG_ASSISTANT = "A" * 1200
|
||||
_LONG_TOOL = "H" * 3000 + "MIDDLE" + "T" * 3000
|
||||
|
||||
|
||||
def _messages() -> List[ChatMessage]:
|
||||
return [
|
||||
ChatMessage(role="system", content="S" * 900),
|
||||
ChatMessage(role="user", content=_LONG_USER),
|
||||
ChatMessage(role="assistant", content=_LONG_ASSISTANT),
|
||||
ChatMessage(role="tool", content=_LONG_TOOL, tool_call_id="c1", tool_name="Bash"),
|
||||
ChatMessage(role="assistant", name="John", content="J" * 800),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 1. 规则
|
||||
|
||||
|
||||
class TestTrimRule:
|
||||
def test_user_kept_in_full(self):
|
||||
assert trim_raw_body("user", _LONG_USER) == _LONG_USER
|
||||
|
||||
def test_system_kept_in_full(self):
|
||||
assert trim_raw_body("system", "S" * 900) == "S" * 900
|
||||
|
||||
def test_long_assistant_cut_at_limit(self):
|
||||
out = trim_raw_body("assistant", _LONG_ASSISTANT)
|
||||
assert out == "A" * DEFAULT_RAW_ASSISTANT_MAX_CHARS + "..."
|
||||
|
||||
def test_short_assistant_untouched(self):
|
||||
assert trim_raw_body("assistant", " short ") == " short "
|
||||
|
||||
def test_other_speaker_cut_like_assistant(self):
|
||||
assert trim_raw_body("John", "J" * 800) == "J" * 500 + "..."
|
||||
|
||||
def test_tool_head_tail_with_marker(self):
|
||||
out = trim_raw_body("tool", _LONG_TOOL, tool_max_bytes=2048)
|
||||
assert out.startswith("H" * 100) and out.endswith("T" * 100)
|
||||
assert "MIDDLE" not in out and _MARKER in out
|
||||
assert len(out.encode("utf-8")) <= 2048
|
||||
|
||||
def test_messages_trimmed_without_mutating_input(self):
|
||||
msgs = _messages()
|
||||
out = trim_raw_messages(msgs, tool_max_bytes=512)
|
||||
assert [m.content for m in msgs][1] == _LONG_USER
|
||||
assert msgs[2].content == _LONG_ASSISTANT, "入参不能被修改"
|
||||
assert out[1].content == _LONG_USER
|
||||
assert out[2].content == "A" * 500 + "..."
|
||||
assert _MARKER in out[3].content and out[3].tool_call_id == "c1"
|
||||
assert out[4].content == "J" * 500 + "..." and out[4].name == "John"
|
||||
|
||||
@pytest.mark.parametrize("max_bytes", [48, 64, 200, 512, 2048])
|
||||
def test_idempotent(self, max_bytes):
|
||||
once = trim_raw_messages(_messages(), tool_max_bytes=max_bytes)
|
||||
twice = trim_raw_messages(once, tool_max_bytes=max_bytes)
|
||||
assert [m.content for m in twice] == [m.content for m in once]
|
||||
for label in ("assistant", "John", "tool", "user", "system"):
|
||||
for body in (_LONG_ASSISTANT, _LONG_TOOL, "中文" * 2000, " x " * 400):
|
||||
t1 = trim_raw_body(label, body, tool_max_bytes=max_bytes)
|
||||
assert trim_raw_body(label, t1, tool_max_bytes=max_bytes) == t1
|
||||
|
||||
def test_preproc_truncations_idempotent(self):
|
||||
t = truncate_tool_result_text(_LONG_TOOL, max_bytes=64)
|
||||
assert truncate_tool_result_text(t, max_bytes=64) == t
|
||||
a = truncate_assistant_text("x" * 10000, head_chars=30, tail_chars=30)
|
||||
assert truncate_assistant_text(a, head_chars=30, tail_chars=30) == a
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 2. lite 端到端
|
||||
|
||||
|
||||
def _lite_config(tmp_path):
|
||||
from hy_memory import MemoryConfig
|
||||
|
||||
return MemoryConfig.from_dict({
|
||||
"vector_store": {
|
||||
"provider": "chroma",
|
||||
"collection_name": f"test_raw_trim_{uuid.uuid4().hex[:8]}",
|
||||
"persist_directory": str(tmp_path / "chroma"),
|
||||
"embedding_dims": 8,
|
||||
},
|
||||
"embedder": {"embedding_dims": 8},
|
||||
"cache": {"backend": "sqlite", "sqlite_path": str(tmp_path / "cache.db")},
|
||||
"history": {"enable": True, "db_path": str(tmp_path / "history.db")},
|
||||
"coding": {"enable": True, "tool_result_max_bytes": 512},
|
||||
})
|
||||
|
||||
|
||||
def _install_fixed_embed(client):
|
||||
vector = [1.0] + [0.0] * 7
|
||||
|
||||
async def one(_self, _text, use_cache=True):
|
||||
return list(vector)
|
||||
|
||||
async def one_meta(_self, _text, use_cache=True):
|
||||
return list(vector), {"cache_hit": False, "provider": "test"}
|
||||
|
||||
async def batch(_self, texts, use_cache=True):
|
||||
return [list(vector) for _ in texts]
|
||||
|
||||
s = client._embed_service
|
||||
s.embed = types.MethodType(one, s)
|
||||
s.embed_queued = types.MethodType(one, s)
|
||||
s.embed_with_meta = types.MethodType(one_meta, s)
|
||||
s.embed_queued_with_meta = types.MethodType(one_meta, s)
|
||||
s.embed_batch = types.MethodType(batch, s)
|
||||
|
||||
|
||||
def test_lite_stores_trimmed_raw(tmp_path, monkeypatch):
|
||||
from hy_memory import HyMemoryClient
|
||||
|
||||
monkeypatch.setenv("MEMORY_DATA_DIR", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("MEMORY_LOG_DIR", str(tmp_path / "logs"))
|
||||
monkeypatch.setenv("MEMORY_PIPELINE_TRACE_ENABLED", "false")
|
||||
client = HyMemoryClient(_lite_config(tmp_path), mode="lite")
|
||||
try:
|
||||
_install_fixed_embed(client)
|
||||
resp = client.add(
|
||||
[
|
||||
{"role": "user", "content": _LONG_USER},
|
||||
{"role": "assistant", "content": _LONG_ASSISTANT,
|
||||
"tool_calls": [{"id": "c1", "type": "function",
|
||||
"function": {"name": "Bash", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": _LONG_TOOL},
|
||||
],
|
||||
user_id="u1",
|
||||
)
|
||||
assert resp["success"] is True, resp
|
||||
|
||||
async def _raw_nodes():
|
||||
return await client._vector_store.list_by_user(
|
||||
user_id="u1", layers=[MemoryLayer.L1_RAW],
|
||||
with_vectors=False, allow_raw_layer=True,
|
||||
)
|
||||
|
||||
nodes = client._loop_thread.run(_raw_nodes())
|
||||
assert len(nodes) == 1
|
||||
content = nodes[0].content
|
||||
assert f"[user]: {_LONG_USER}" in content, "user 必须完整保留"
|
||||
assert f"[assistant]: {'A' * 500}...\n" in content
|
||||
tool_line = content.split("[tool]: ", 1)[1]
|
||||
assert tool_line.startswith("H") and tool_line.endswith("T")
|
||||
assert _MARKER in tool_line and "MIDDLE" not in tool_line
|
||||
assert len(tool_line.encode("utf-8")) <= 512
|
||||
assert (nodes[0].custom or {}).get(RAW_TRIMMED_KEY) is True
|
||||
|
||||
conn = sqlite3.connect(str(tmp_path / "history.db"))
|
||||
try:
|
||||
(audit,) = conn.execute(
|
||||
"SELECT new_memory FROM memory_history WHERE event='ADD'"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert _LONG_USER in audit and "MIDDLE" not in audit
|
||||
assert "A" * 501 not in audit
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 3. coding_trace
|
||||
|
||||
|
||||
def test_coding_trace_stores_truncated_tool_result(tmp_path):
|
||||
from hy_memory.coding.writer import CodingWriter
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
class _Store:
|
||||
config = types.SimpleNamespace(
|
||||
coding=types.SimpleNamespace(tool_result_max_bytes=512),
|
||||
extractor=types.SimpleNamespace(history_turns=5),
|
||||
)
|
||||
|
||||
async def get_recent_trace_messages(self, **kw):
|
||||
return []
|
||||
|
||||
async def append_trace(self, messages, **kw):
|
||||
captured["messages"] = messages
|
||||
|
||||
class _Extractor:
|
||||
async def extract(self, messages, **kw):
|
||||
return [], []
|
||||
|
||||
w = CodingWriter.__new__(CodingWriter)
|
||||
w.variant = "working"
|
||||
w.store = _Store()
|
||||
w.extractor = _Extractor()
|
||||
asyncio.run(w.write(_messages(), user_id="u1"))
|
||||
|
||||
stored = {m.role: m.content for m in captured["messages"] if m.role != "assistant"}
|
||||
assert stored["user"] == _LONG_USER
|
||||
assert _MARKER in stored["tool"] and "MIDDLE" not in stored["tool"]
|
||||
assert len(stored["tool"].encode("utf-8")) <= 512
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 4. pro writer
|
||||
|
||||
|
||||
class _Store:
|
||||
supports_fulltext = False
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: Dict[str, MemoryNode] = {}
|
||||
|
||||
async def initialize(self):
|
||||
pass
|
||||
|
||||
async def upsert(self, node: MemoryNode) -> str:
|
||||
node.node_id = node.node_id or uuid.uuid4().hex
|
||||
if node.gmt_created is None:
|
||||
node.gmt_created = datetime.now()
|
||||
self.nodes[node.node_id] = node
|
||||
return node.node_id
|
||||
|
||||
async def get_by_id(self, node_id):
|
||||
return self.nodes.get(node_id)
|
||||
|
||||
async def list_by_user(self, *a, layers=None, **kw):
|
||||
return [
|
||||
n for n in self.nodes.values()
|
||||
if not layers or n.layer in layers
|
||||
]
|
||||
|
||||
async def search(self, *a, **kw):
|
||||
return []
|
||||
|
||||
|
||||
class _Embed:
|
||||
async def embed_queued_with_meta(self, text):
|
||||
return [0.0] * 8, {"provider": "test"}
|
||||
|
||||
async def embed_queued(self, text):
|
||||
return [0.0] * 8
|
||||
|
||||
async def embed_batch(self, texts):
|
||||
return [[0.0] * 8 for _ in texts]
|
||||
|
||||
|
||||
class _CapturingAgent:
|
||||
def __init__(self):
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
|
||||
async def process_add(self, **kwargs):
|
||||
from tests.test_shadow_l1_raw_on_empty_extract import _FakeAgentResult
|
||||
|
||||
self.calls.append(kwargs)
|
||||
return _FakeAgentResult(success=True, extracted_info={"memory": [], "intentions": []})
|
||||
|
||||
|
||||
def _pro_writer(store):
|
||||
from hy_memory.config import MemoryConfig
|
||||
from hy_memory.pipelines.writer import MemoryWriter
|
||||
|
||||
w = MemoryWriter(MemoryConfig(), embed_service=_Embed(), vector_store=store, cache=None)
|
||||
w._mem_agent = _CapturingAgent()
|
||||
w._vector_store = store
|
||||
|
||||
async def _empty_tags(*a, **kw):
|
||||
return []
|
||||
|
||||
w._collect_existing_tags = _empty_tags
|
||||
return w
|
||||
|
||||
|
||||
def _write(w, msgs: List[ChatMessage]):
|
||||
req = WriteRequest(
|
||||
content="", messages=msgs, user_id="u1", agent_id="default_agent",
|
||||
session_id="s1", content_type="messages",
|
||||
extra={"agent_mode": "full", "mode": "pro"},
|
||||
request_id=uuid.uuid4().hex,
|
||||
)
|
||||
return asyncio.run(w.write(req))
|
||||
|
||||
|
||||
def test_pro_extraction_input_unchanged_and_history_concatenates():
|
||||
store = _Store()
|
||||
w = _pro_writer(store)
|
||||
turn1 = [
|
||||
ChatMessage(role="user", content=_LONG_USER),
|
||||
ChatMessage(role="assistant", content=_LONG_ASSISTANT),
|
||||
]
|
||||
turn2 = [
|
||||
ChatMessage(role="user", content="第二轮"),
|
||||
ChatMessage(role="assistant", content="B" * 900),
|
||||
]
|
||||
r1 = _write(w, turn1)
|
||||
assert r1.memory_id
|
||||
r2 = _write(w, turn2)
|
||||
assert r2.memory_id
|
||||
|
||||
calls = w._mem_agent.calls
|
||||
assert len(calls) == 2
|
||||
# extractor 收到的仍是完整 messages(没有被落库裁剪影响)
|
||||
for call, msgs in zip(calls, (turn1, turn2)):
|
||||
assert json.loads(call["content"]) == [
|
||||
{"role": m.role, "content": m.content} for m in msgs
|
||||
]
|
||||
|
||||
raw1 = store.nodes[r1.memory_id]
|
||||
assert raw1.content == f"[user]: {_LONG_USER}\n[assistant]: {'A' * 500}..."
|
||||
assert raw1.custom.get(RAW_TRIMMED_KEY) is True
|
||||
# 历史 = 已存 raw 直接拼接(第一次写入时没有历史)
|
||||
assert calls[0]["history_context"] == ""
|
||||
assert calls[1]["history_context"] == raw1.content
|
||||
|
||||
|
||||
def test_history_does_not_retrim_new_raw_but_trims_legacy_raw():
|
||||
"""新 raw(带标记)原样拼接;旧 raw(无标记)按同一规则兼容裁剪。"""
|
||||
from hy_memory.pipelines.writer import MemoryWriter
|
||||
|
||||
store = _Store()
|
||||
base = datetime.now()
|
||||
# 新 raw:内容里刻意放一条超过 500 的 assistant——带标记就不应再被裁
|
||||
new_content = "[user]: 新问题\n[assistant]: " + "N" * 700
|
||||
legacy_content = "[user]: 旧问题\n[assistant]: " + "O" * 700 + "\n[tool]: " + _LONG_TOOL
|
||||
store.nodes["legacy"] = MemoryNode(
|
||||
node_id="legacy", user_id="u1", agent_id="default_agent",
|
||||
layer=MemoryLayer.L1_RAW, content=legacy_content,
|
||||
status=MemoryStatus.ACTIVE, gmt_created=base - timedelta(seconds=2),
|
||||
)
|
||||
store.nodes["new"] = MemoryNode(
|
||||
node_id="new", user_id="u1", agent_id="default_agent",
|
||||
layer=MemoryLayer.L1_RAW, content=new_content,
|
||||
status=MemoryStatus.ACTIVE, gmt_created=base - timedelta(seconds=1),
|
||||
custom={RAW_TRIMMED_KEY: True},
|
||||
)
|
||||
w = MemoryWriter.__new__(MemoryWriter)
|
||||
history = asyncio.run(w._get_recent_history(store, "u1", "default_agent", max_turns=20))
|
||||
|
||||
assert history.endswith(new_content), "新 raw 必须原样拼接"
|
||||
assert "[assistant]: " + "O" * 500 + "...\n" in history
|
||||
assert "MIDDLE" not in history and _MARKER in history
|
||||
@@ -0,0 +1,642 @@
|
||||
"""Re-embed into a new embedding space: vector stores, scan, diff and merge.
|
||||
|
||||
The switch model: writes are paused, every vector is computed again with the
|
||||
new model into a new space, the source is never touched. The graph half lives
|
||||
in ``test_reembed_graph.py``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory import (
|
||||
EmbeddingDimensionMismatchError,
|
||||
MemoryConfig,
|
||||
ReembedCancelled,
|
||||
VectorSpace,
|
||||
diff_spaces,
|
||||
drop_vector_space,
|
||||
plan_reembed,
|
||||
reembed_text,
|
||||
reembed_vector_space,
|
||||
scan_vector_spaces,
|
||||
space_identity,
|
||||
vector_space_names,
|
||||
verify_reembed,
|
||||
)
|
||||
from hy_memory.config import EmbedderConfig
|
||||
from hy_memory.core.embed_service import EmbedService
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryNode, MemoryStatus
|
||||
from hy_memory.reembed import VECTOR_ROLES, parse_collection_name
|
||||
|
||||
|
||||
class FakeEmbedder:
|
||||
"""Deterministic vectors: same (model, text) -> same unit vector."""
|
||||
|
||||
def __init__(self, model: str, dims: int, *, wrong_width_after: int = -1):
|
||||
self.model = model
|
||||
self.dims = dims
|
||||
self.calls = []
|
||||
self.texts = 0
|
||||
self.wrong_width_after = wrong_width_after
|
||||
|
||||
def vector(self, text: str):
|
||||
seed = int(hashlib.sha256(f"{self.model}\0{text}".encode()).hexdigest()[:16], 16)
|
||||
rng = random.Random(seed)
|
||||
values = [rng.gauss(0.0, 1.0) for _ in range(self.dims)]
|
||||
norm = math.sqrt(sum(v * v for v in values)) or 1.0
|
||||
return [v / norm for v in values]
|
||||
|
||||
async def embed_batch(self, texts, use_cache=True):
|
||||
self.calls.append(list(texts))
|
||||
out = []
|
||||
for text in texts:
|
||||
self.texts += 1
|
||||
vector = self.vector(text)
|
||||
if 0 <= self.wrong_width_after < self.texts:
|
||||
vector = vector + [0.0]
|
||||
out.append(vector)
|
||||
return out
|
||||
|
||||
async def embed(self, text, use_cache=True):
|
||||
return self.vector(text)
|
||||
|
||||
async def embed_queued(self, text, use_cache=True):
|
||||
return self.vector(text)
|
||||
|
||||
def get_embedding_dims(self):
|
||||
return self.dims
|
||||
|
||||
|
||||
def _client(path):
|
||||
import chromadb
|
||||
return chromadb.PersistentClient(path=str(path))
|
||||
|
||||
|
||||
def _space(path, base, dims, model=None):
|
||||
return VectorSpace(base_name=base, dims=dims, persist_directory=str(path), model=model)
|
||||
|
||||
|
||||
def _custom_state():
|
||||
return {
|
||||
"memory_kind": "canonical_state",
|
||||
"state_key": "home_city",
|
||||
"state_value": "Berlin",
|
||||
"state_retrieval_text": "user lives in Berlin (home city)",
|
||||
}
|
||||
|
||||
|
||||
async def _seed(path, base, dims, *, model="old-model", users=("alice", "bob"), per_user=7):
|
||||
"""Write a realistic source space through the SDK's own stores."""
|
||||
from hy_memory.data.vector_store_chroma import ChromaVectorStore
|
||||
|
||||
old = FakeEmbedder(model, dims)
|
||||
cfg = MemoryConfig()
|
||||
cfg.vector_store.provider = "chroma"
|
||||
cfg.vector_store.persist_directory = str(path)
|
||||
cfg.vector_store.collection_name = base
|
||||
cfg.vector_store.embedding_dims = dims
|
||||
store = ChromaVectorStore(cfg)
|
||||
await store.initialize()
|
||||
nodes = []
|
||||
for user in users:
|
||||
for i in range(per_user):
|
||||
layer = [MemoryLayer.L1_RAW, MemoryLayer.L2_FACT, MemoryLayer.L3_SUMMARY][i % 3]
|
||||
node = MemoryNode(
|
||||
user_id=user, agent_id="a", session_id=f"s{i % 2}",
|
||||
layer=layer, content=f"{user} fact {i}",
|
||||
status=MemoryStatus.SUPERSEDED if i == 4 else MemoryStatus.ACTIVE,
|
||||
tags=["t1", f"t{i}"],
|
||||
custom={"k": i, "nested": {"x": [1, 2]}},
|
||||
)
|
||||
if i == 4:
|
||||
node.is_latest = False
|
||||
node.superseded_by = ["later"]
|
||||
if i == 5:
|
||||
node.custom = _custom_state()
|
||||
node.embedding = old.vector(node.content)
|
||||
nodes.append(node)
|
||||
await store.upsert_batch(nodes)
|
||||
entities = await store._get_entity_collection()
|
||||
entities.add(
|
||||
ids=["e1", "e2"],
|
||||
embeddings=[old.vector("Berlin"), old.vector("Dylan")],
|
||||
documents=["Berlin", "Dylan"],
|
||||
metadatas=[{"user_id": "alice", "label": "GPE"}, {"user_id": "bob", "label": "PERSON"}],
|
||||
)
|
||||
await store.close()
|
||||
client = _client(path)
|
||||
names = vector_space_names(_space(path, base, dims))
|
||||
for role, doc in (("coding_keys", "fix the flaky test"), ("working_facts_cv", "repo: uses uv")):
|
||||
coll = client.get_or_create_collection(names[role], metadata={"hnsw:space": "cosine"})
|
||||
coll.add(
|
||||
ids=[f"{role}:0"],
|
||||
embeddings=[old.vector(doc)],
|
||||
documents=[doc],
|
||||
metadatas=[{"user_id": "alice", "layer": "l1_raw",
|
||||
"custom": json.dumps({"coding_key_text": doc})}],
|
||||
)
|
||||
return names
|
||||
|
||||
|
||||
def _snapshot(path, names):
|
||||
client = _client(path)
|
||||
out = {}
|
||||
for role, name in names.items():
|
||||
try:
|
||||
coll = client.get_collection(name)
|
||||
except Exception:
|
||||
continue
|
||||
got = coll.get(include=["documents", "metadatas", "embeddings"])
|
||||
out[role] = sorted(
|
||||
(rid, doc, json.dumps(meta, sort_keys=True), [round(float(x), 6) for x in emb])
|
||||
for rid, doc, meta, emb in zip(got["ids"], got["documents"], got["metadatas"], got["embeddings"])
|
||||
)
|
||||
return hashlib.sha256(json.dumps(out, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def _records(path, name):
|
||||
got = _client(path).get_collection(name).get(include=["documents", "metadatas", "embeddings"])
|
||||
return {
|
||||
rid: (doc, meta, list(emb))
|
||||
for rid, doc, meta, emb in zip(got["ids"], got["documents"], got["metadatas"], got["embeddings"])
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# naming
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
class TestVectorSpaceNames:
|
||||
@pytest.mark.parametrize("dims", [512, None])
|
||||
async def test_names_equal_what_the_stores_open(self, tmp_path, dims):
|
||||
from hy_memory.coding.store import CodingMemoryStore
|
||||
from hy_memory.data.vector_store_chroma import ChromaVectorStore
|
||||
|
||||
cfg = MemoryConfig()
|
||||
cfg.vector_store.provider = "chroma"
|
||||
cfg.vector_store.persist_directory = str(tmp_path / "v")
|
||||
cfg.vector_store.collection_name = "memhub_abc123"
|
||||
cfg.vector_store.embedding_dims = dims
|
||||
cfg.history.db_path = str(tmp_path / "history.sqlite3")
|
||||
store = ChromaVectorStore(cfg)
|
||||
await store.initialize()
|
||||
await store._get_entity_collection()
|
||||
coding = []
|
||||
for variant in ("working", "coding"):
|
||||
cs = CodingMemoryStore(cfg, FakeEmbedder("m", dims or 8), variant=variant)
|
||||
await cs.initialize()
|
||||
coding.append(cs)
|
||||
opened = {c.name for c in _client(tmp_path / "v").list_collections()}
|
||||
names = vector_space_names(cfg)
|
||||
assert set(names) == set(VECTOR_ROLES)
|
||||
assert set(names.values()) == opened
|
||||
suffix = f"_{dims}" if dims else ""
|
||||
assert names["memory"] == f"memhub_abc123{suffix}"
|
||||
assert names["coding_keys_cv"] == f"memhub_abc123_coding_keys_cv{suffix}"
|
||||
await store.close()
|
||||
for cs in coding:
|
||||
await cs.close()
|
||||
|
||||
def test_explicit_base_name_and_space(self, tmp_path):
|
||||
cfg = MemoryConfig()
|
||||
cfg.vector_store.provider = "chroma"
|
||||
cfg.vector_store.embedding_dims = 1024
|
||||
names = vector_space_names(cfg, base_name="staging_x")
|
||||
assert names["memory"] == "staging_x_1024"
|
||||
assert names["entities"] == "staging_x_1024_entities"
|
||||
assert names["working_facts"] == "staging_x_working_facts_1024"
|
||||
assert vector_space_names(_space(tmp_path, "b", 512))["memory"] == "b_512"
|
||||
|
||||
def test_parse_collection_name_round_trips(self):
|
||||
for dims in (512, None):
|
||||
for role, name in vector_space_names(VectorSpace("memhub_9f", dims)).items():
|
||||
assert parse_collection_name(name) == ("memhub_9f", dims, role)
|
||||
assert parse_collection_name("memhub_9f_512_tag_index") is None
|
||||
|
||||
def test_space_identity_is_model_and_width_only(self):
|
||||
a = space_identity("BAAI/bge-small-zh-v1.5", 512)
|
||||
b = space_identity(" BAAI/bge-small-zh-v1.5 ", 512)
|
||||
assert a == b and a.key == "BAAI/bge-small-zh-v1.5#512"
|
||||
assert a.digest != space_identity("BAAI/bge-small-zh-v1.5", 1024).digest
|
||||
with pytest.raises(ValueError):
|
||||
space_identity("", 512)
|
||||
|
||||
def test_non_chroma_provider_is_not_supported(self):
|
||||
cfg = MemoryConfig()
|
||||
cfg.vector_store.provider = "qdrant"
|
||||
with pytest.raises(NotImplementedError):
|
||||
vector_space_names(cfg)
|
||||
|
||||
|
||||
class TestReembedText:
|
||||
def test_canonical_state_uses_state_retrieval_text(self):
|
||||
meta = {"content": "住在柏林", "custom": json.dumps(_custom_state())}
|
||||
assert reembed_text("memory", "住在柏林", meta) == "user lives in Berlin (home city)"
|
||||
|
||||
def test_retrieval_text_when_present(self):
|
||||
meta = {"custom": json.dumps({"retrieval_text": " pattern: plans ahead "})}
|
||||
assert reembed_text("memory", "画像", meta) == "pattern: plans ahead"
|
||||
|
||||
def test_document_otherwise_with_content_fallback(self):
|
||||
assert reembed_text("memory", "plain fact", {"custom": "{}"}) == "plain fact"
|
||||
assert reembed_text("memory", None, {"content": "from metadata"}) == "from metadata"
|
||||
assert reembed_text("memory", "doc", {"custom": "not json"}) == "doc"
|
||||
|
||||
def test_coding_key_and_working_fact_use_the_document(self):
|
||||
coding = {"custom": json.dumps({"coding_key_text": "fix flaky test",
|
||||
"retrieval_text": "ignored outside memory"})}
|
||||
assert reembed_text("coding_keys", "fix flaky test", coding) == "fix flaky test"
|
||||
assert reembed_text("working_facts", "repo: uses uv", {}) == "repo: uses uv"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# re-embed
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"src_dims,tgt_dims,src_model,tgt_model",
|
||||
[(512, 512, "old-model", "new-model"), (512, 1024, "old", "new"), (1024, 512, "old", "new")],
|
||||
)
|
||||
async def test_reembed_copies_everything_and_never_touches_source(
|
||||
tmp_path, src_dims, tgt_dims, src_model, tgt_model,
|
||||
):
|
||||
path = tmp_path / "v"
|
||||
names = await _seed(path, "memhub_src", src_dims, model=src_model)
|
||||
before = _snapshot(path, names)
|
||||
source = _space(path, "memhub_src", src_dims)
|
||||
target = _space(path, "memhub_dst", tgt_dims, model=tgt_model)
|
||||
new = FakeEmbedder(tgt_model, tgt_dims)
|
||||
|
||||
plan = await plan_reembed(source, target)
|
||||
assert plan.per_role["memory"] == 14 and plan.per_role["entities"] == 2
|
||||
assert plan.total_vectors == 18 == plan.estimated_vectors
|
||||
assert plan.source_dims == src_dims and plan.target_dims == tgt_dims
|
||||
|
||||
seen = []
|
||||
report = await reembed_vector_space(
|
||||
source, target, embedding=new, batch_size=4, on_progress=seen.append,
|
||||
)
|
||||
assert report.embedded == 18 and report.reused == 0
|
||||
assert seen and seen[-1].done == seen[-1].total
|
||||
|
||||
assert _snapshot(path, names) == before # source unchanged, byte for byte
|
||||
target_names = vector_space_names(target)
|
||||
for role in ("memory", "entities", "coding_keys", "working_facts_cv"):
|
||||
src = _records(path, names[role])
|
||||
dst = _records(path, target_names[role])
|
||||
assert set(src) == set(dst)
|
||||
for rid, (doc, meta, _) in src.items():
|
||||
assert dst[rid][0] == doc
|
||||
assert dst[rid][1] == meta # status, is_latest, superseded_by, timestamps, custom
|
||||
assert len(dst[rid][2]) == tgt_dims
|
||||
assert dst[rid][2] == pytest.approx(new.vector(reembed_text(role, doc, meta)), abs=1e-5)
|
||||
memory = _records(path, target_names["memory"])
|
||||
layers = {meta["layer"] for _, meta, _ in memory.values()}
|
||||
users = {meta["user_id"] for _, meta, _ in memory.values()}
|
||||
assert "l1_raw" in layers and users == {"alice", "bob"}
|
||||
statuses = {meta["status"] for _, meta, _ in memory.values()}
|
||||
assert statuses == {"active", "superseded"}
|
||||
state = [r for r in memory.values() if "canonical_state" in r[1]["custom"]]
|
||||
assert state and state[0][2] == pytest.approx(
|
||||
new.vector("user lives in Berlin (home city)"), abs=1e-5,
|
||||
)
|
||||
meta = _client(path).get_collection(target_names["memory"]).metadata
|
||||
assert meta["hnsw:space"] == "cosine" and meta["hy_embedding_model"] == tgt_model
|
||||
|
||||
verified = await verify_reembed(source, target, embedding=new)
|
||||
assert verified.ok, verified.problems
|
||||
assert verified.per_user == {"alice": 10, "bob": 8}
|
||||
assert verified.self_recall_samples == 18 and verified.self_recall_hits == 18
|
||||
|
||||
|
||||
async def test_resume_after_kill_only_embeds_the_rest(tmp_path):
|
||||
path = tmp_path / "v"
|
||||
await _seed(path, "src", 512, per_user=20)
|
||||
source, target = _space(path, "src", 512), _space(path, "dst", 1024)
|
||||
|
||||
class Killed(Exception):
|
||||
pass
|
||||
|
||||
first = FakeEmbedder("new", 1024)
|
||||
batches = {"n": 0}
|
||||
|
||||
def _kill():
|
||||
batches["n"] += 1
|
||||
if batches["n"] > 3:
|
||||
raise Killed()
|
||||
return False
|
||||
|
||||
with pytest.raises(Killed):
|
||||
await reembed_vector_space(
|
||||
source, target, embedding=first, batch_size=5, max_inflight=1,
|
||||
roles=["memory"], cancel=_kill,
|
||||
)
|
||||
assert first.texts == 15
|
||||
second = FakeEmbedder("new", 1024)
|
||||
report = await reembed_vector_space(
|
||||
source, target, embedding=second, batch_size=5, max_inflight=1, roles=["memory"],
|
||||
)
|
||||
assert second.texts == 40 - 15
|
||||
assert report.per_role["memory"].reused == 15
|
||||
# The end state equals an uninterrupted run: every source record, verbatim,
|
||||
# with the vector of its own text.
|
||||
resumed = _records(path, "dst_1024")
|
||||
src = _records(path, "src_512")
|
||||
assert set(resumed) == set(src)
|
||||
for rid, (doc, meta, vector) in resumed.items():
|
||||
assert (doc, meta) == src[rid][:2]
|
||||
assert vector == pytest.approx(second.vector(reembed_text("memory", doc, meta)), abs=1e-5)
|
||||
assert (await verify_reembed(source, target, roles=["memory"], samples=0)).ok
|
||||
|
||||
|
||||
async def test_cancel_at_batch_boundary_leaves_no_partial_batch(tmp_path):
|
||||
path = tmp_path / "v"
|
||||
await _seed(path, "src", 512)
|
||||
calls = {"n": 0}
|
||||
|
||||
def _cancel():
|
||||
calls["n"] += 1
|
||||
return calls["n"] > 2
|
||||
|
||||
with pytest.raises(ReembedCancelled) as info:
|
||||
await reembed_vector_space(
|
||||
_space(path, "src", 512), _space(path, "dst", 512),
|
||||
embedding=FakeEmbedder("new", 512), batch_size=3, max_inflight=1,
|
||||
roles=["memory"], cancel=_cancel,
|
||||
)
|
||||
assert info.value.report.per_role["memory"].embedded == 6
|
||||
assert _client(path).get_collection("dst_512").count() == 6
|
||||
|
||||
|
||||
async def test_wrong_width_raises_before_any_upsert(tmp_path):
|
||||
path = tmp_path / "v"
|
||||
await _seed(path, "src", 512)
|
||||
bad = FakeEmbedder("new", 1024, wrong_width_after=2)
|
||||
with pytest.raises(EmbeddingDimensionMismatchError):
|
||||
await reembed_vector_space(
|
||||
_space(path, "src", 512), _space(path, "dst", 1024),
|
||||
embedding=bad, batch_size=4, roles=["memory"],
|
||||
)
|
||||
assert _client(path).get_collection("dst_1024").count() == 0
|
||||
declared_wrong = FakeEmbedder("new", 768)
|
||||
with pytest.raises(EmbeddingDimensionMismatchError):
|
||||
await reembed_vector_space(
|
||||
_space(path, "src", 512), _space(path, "dst", 1024), embedding=declared_wrong,
|
||||
)
|
||||
|
||||
|
||||
async def test_refuses_to_write_into_the_source_collection(tmp_path):
|
||||
path = tmp_path / "v"
|
||||
await _seed(path, "src", 512)
|
||||
with pytest.raises(ValueError):
|
||||
await reembed_vector_space(
|
||||
_space(path, "src", 512), _space(path, "src", 512),
|
||||
embedding=FakeEmbedder("new", 512),
|
||||
)
|
||||
|
||||
|
||||
async def test_verify_detects_missing_extra_drift_and_width(tmp_path):
|
||||
path = tmp_path / "v"
|
||||
await _seed(path, "src", 512)
|
||||
source, target = _space(path, "src", 512), _space(path, "dst", 512)
|
||||
new = FakeEmbedder("new", 512)
|
||||
await reembed_vector_space(source, target, embedding=new, roles=["memory"])
|
||||
coll = _client(path).get_collection("dst_512")
|
||||
got = coll.get(include=["documents", "metadatas"])
|
||||
victim, drifted = got["ids"][0], got["ids"][1]
|
||||
coll.delete(ids=[victim])
|
||||
meta = dict(got["metadatas"][1])
|
||||
meta["status"] = "archived"
|
||||
coll.update(ids=[drifted], metadatas=[meta])
|
||||
coll.add(ids=["stranger"], embeddings=[new.vector("x")], documents=["x"], metadatas=[{"user_id": "eve"}])
|
||||
result = await verify_reembed(source, target, embedding=new, roles=["memory"], samples=5)
|
||||
assert not result.ok
|
||||
counts = result.per_role["memory"]
|
||||
assert (counts["missing"], counts["extra"], counts["mismatched"]) == (1, 1, 1)
|
||||
assert any("eve" in p for p in result.problems)
|
||||
|
||||
wide = _space(path, "dst", 1024)
|
||||
wide_coll = _client(path).get_or_create_collection("dst_1024", metadata={"hnsw:space": "cosine"})
|
||||
src = _records(path, "src_512")
|
||||
wide_coll.add(
|
||||
ids=list(src),
|
||||
embeddings=[[0.0] * 512 for _ in src],
|
||||
documents=[v[0] for v in src.values()],
|
||||
metadatas=[v[1] for v in src.values()],
|
||||
)
|
||||
result = await verify_reembed(source, wide, roles=["memory"], samples=0)
|
||||
assert result.per_role["memory"]["bad_dims"] == len(src) and not result.ok
|
||||
|
||||
|
||||
async def test_drop_vector_space_refuses_live_collections(tmp_path):
|
||||
from hy_memory.data.vector_store_chroma import ChromaVectorStore
|
||||
|
||||
path = tmp_path / "v"
|
||||
await _seed(path, "src", 512)
|
||||
cfg = MemoryConfig()
|
||||
cfg.vector_store.provider = "chroma"
|
||||
cfg.vector_store.persist_directory = str(path)
|
||||
cfg.vector_store.collection_name = "src"
|
||||
cfg.vector_store.embedding_dims = 512
|
||||
live = ChromaVectorStore(cfg)
|
||||
await live.initialize()
|
||||
with pytest.raises(RuntimeError):
|
||||
await drop_vector_space(_space(path, "src", 512))
|
||||
await live.close()
|
||||
dropped = await drop_vector_space(_space(path, "src", 512))
|
||||
assert set(dropped) == {"src_512", "src_512_entities", "src_coding_keys_512", "src_working_facts_cv_512"}
|
||||
assert not {c.name for c in _client(path).list_collections()}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# scan, diff, merge
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
async def _two_spaces(path):
|
||||
"""A live 1024 space and a stray 512 space sharing some ids."""
|
||||
from hy_memory.data.vector_store_chroma import ChromaVectorStore
|
||||
|
||||
async def _store(base, dims, model, contents):
|
||||
cfg = MemoryConfig()
|
||||
cfg.vector_store.provider = "chroma"
|
||||
cfg.vector_store.persist_directory = str(path)
|
||||
cfg.vector_store.collection_name = base
|
||||
cfg.vector_store.embedding_dims = dims
|
||||
store = ChromaVectorStore(cfg)
|
||||
await store.initialize()
|
||||
emb = FakeEmbedder(model, dims)
|
||||
nodes = []
|
||||
for node_id, user, content, status in contents:
|
||||
node = MemoryNode(
|
||||
node_id=node_id, user_id=user, agent_id="a", session_id="s",
|
||||
layer=MemoryLayer.L2_FACT, content=content, status=status,
|
||||
)
|
||||
node.embedding = emb.vector(content)
|
||||
nodes.append(node)
|
||||
await store.upsert_batch(nodes)
|
||||
await store.close()
|
||||
|
||||
ids = [f"00000000-0000-0000-0000-00000000000{i}" for i in range(6)]
|
||||
await _store("memhub_old", 512, "bge", [
|
||||
(ids[0], "alice", "shared one", MemoryStatus.ACTIVE),
|
||||
(ids[1], "alice", "shared two (stray wording)", MemoryStatus.ACTIVE),
|
||||
(ids[2], "bob", "only in stray", MemoryStatus.ACTIVE),
|
||||
(ids[3], "bob", "only in stray, deleted", MemoryStatus.SHADOW),
|
||||
])
|
||||
await _store("memhub_new", 1024, "e5", [
|
||||
(ids[0], "alice", "shared one", MemoryStatus.ACTIVE),
|
||||
(ids[1], "alice", "shared two (live wording)", MemoryStatus.ARCHIVED),
|
||||
(ids[4], "carol", "only in live", MemoryStatus.ACTIVE),
|
||||
])
|
||||
return ids
|
||||
|
||||
|
||||
async def test_scan_diff_and_merge(tmp_path):
|
||||
path = tmp_path / "v"
|
||||
ids = await _two_spaces(path)
|
||||
spaces = await scan_vector_spaces(path)
|
||||
by_base = {(s.base_name, s.dims): s for s in spaces}
|
||||
assert set(by_base) == {("memhub_old", 512), ("memhub_new", 1024)}
|
||||
old, live = by_base[("memhub_old", 512)], by_base[("memhub_new", 1024)]
|
||||
assert old.roles["memory"].count == 4 and old.width == 512
|
||||
assert live.roles["memory"].count == 3 and live.width == 1024
|
||||
assert old.per_user == {"alice": 2, "bob": 2}
|
||||
assert live.per_user == {"alice": 2, "carol": 1}
|
||||
|
||||
stray, target = old.space(), _space(path, "memhub_new", 1024, model="e5")
|
||||
diff = await diff_spaces(stray, target)
|
||||
assert set(diff.missing["memory"]) == {ids[2], ids[3]} # status ignored
|
||||
assert diff.total_missing == 2
|
||||
|
||||
live_before = _records(path, "memhub_new_1024")
|
||||
stray_before = _snapshot(path, stray.names())
|
||||
merger = FakeEmbedder("e5", 1024)
|
||||
report = await reembed_vector_space(stray, target, embedding=merger)
|
||||
assert merger.texts == 2 and report.embedded == 2
|
||||
assert report.reused == 1 and report.kept == 1
|
||||
after = _records(path, "memhub_new_1024")
|
||||
assert set(after) == set(live_before) | {ids[2], ids[3]}
|
||||
for rid, record in live_before.items(): # the target wins
|
||||
assert after[rid][:2] == record[:2]
|
||||
assert after[rid][2] == pytest.approx(record[2], abs=1e-6)
|
||||
assert _snapshot(path, stray.names()) == stray_before
|
||||
assert (await diff_spaces(stray, target)).empty
|
||||
|
||||
again = FakeEmbedder("e5", 1024)
|
||||
second = await reembed_vector_space(stray, target, embedding=again)
|
||||
assert again.texts == 0 and second.embedded == 0
|
||||
assert _records(path, "memhub_new_1024").keys() == after.keys()
|
||||
rescanned = {(s.base_name, s.dims): s for s in await scan_vector_spaces(path)}
|
||||
assert rescanned[("memhub_new", 1024)].roles["memory"].count == 5
|
||||
|
||||
|
||||
async def test_scan_of_missing_directory_is_empty(tmp_path):
|
||||
assert await scan_vector_spaces(tmp_path / "nope") == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# EmbedService fixes
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def _fake_raw(n, dims):
|
||||
items = []
|
||||
for i in range(n):
|
||||
item = MagicMock()
|
||||
item.embedding = [0.1] * dims
|
||||
item.index = i
|
||||
items.append(item)
|
||||
resp = MagicMock()
|
||||
resp.model = "m"
|
||||
resp.usage = None
|
||||
resp.data = items
|
||||
raw = MagicMock()
|
||||
raw.status_code = 200
|
||||
raw.headers = {}
|
||||
raw.parse.return_value = resp
|
||||
return raw
|
||||
|
||||
|
||||
def _service(dims=8, **embedder):
|
||||
cfg = MemoryConfig(embedder=EmbedderConfig(
|
||||
provider="openai", model="m", api_key="sk", base_url="https://e.invalid/v1",
|
||||
embedding_dims=dims, max_retries=2, retry_delay=0.0, **embedder,
|
||||
))
|
||||
return EmbedService(cfg)
|
||||
|
||||
|
||||
async def test_embed_batch_chunks_to_batch_size():
|
||||
svc = _service()
|
||||
sizes = []
|
||||
|
||||
async def _create(**kwargs):
|
||||
sizes.append(len(kwargs["input"]))
|
||||
return _fake_raw(len(kwargs["input"]), 8)
|
||||
|
||||
client = MagicMock()
|
||||
client.embeddings.with_raw_response.create = AsyncMock(side_effect=_create)
|
||||
with patch("openai.AsyncOpenAI", return_value=client):
|
||||
vectors = await svc.embed_batch([f"t{i}" for i in range(1000)], use_cache=False)
|
||||
assert len(vectors) == 1000
|
||||
assert len(sizes) == math.ceil(1000 / 64) and max(sizes) == 64
|
||||
|
||||
small = _service(batch_size=10)
|
||||
sizes.clear()
|
||||
with patch("openai.AsyncOpenAI", return_value=client):
|
||||
_, metas = await small.embed_batch_with_meta([f"u{i}" for i in range(25)], use_cache=False)
|
||||
assert sizes == [10, 10, 5]
|
||||
assert [m["batch_index"] for m in metas] == list(range(25))
|
||||
|
||||
|
||||
async def test_429_honours_retry_after():
|
||||
svc = _service()
|
||||
|
||||
class RateLimited(Exception):
|
||||
status_code = 429
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("rate limited")
|
||||
self.response = MagicMock()
|
||||
self.response.headers = {"retry-after": "2"}
|
||||
|
||||
client = MagicMock()
|
||||
client.embeddings.with_raw_response.create = AsyncMock(
|
||||
side_effect=[RateLimited(), _fake_raw(1, 8)],
|
||||
)
|
||||
sleeps = []
|
||||
|
||||
async def _sleep(seconds):
|
||||
sleeps.append(seconds)
|
||||
|
||||
with patch("openai.AsyncOpenAI", return_value=client), \
|
||||
patch("hy_memory.core.embed_service.asyncio.sleep", _sleep):
|
||||
vectors = await svc.embed_batch(["x"], use_cache=False)
|
||||
assert len(vectors) == 1
|
||||
assert len(sleeps) == 1 and 2.0 <= sleeps[0] <= 2.5
|
||||
|
||||
|
||||
async def test_per_call_max_retries():
|
||||
svc = _service()
|
||||
|
||||
class Down(Exception):
|
||||
status_code = 503
|
||||
|
||||
client = MagicMock()
|
||||
client.embeddings.with_raw_response.create = AsyncMock(side_effect=Down("down"))
|
||||
with patch("openai.AsyncOpenAI", return_value=client), pytest.raises(Down):
|
||||
await svc.embed_batch(["x"], use_cache=False, max_retries=5)
|
||||
assert client.embeddings.with_raw_response.create.await_count == 6
|
||||
|
||||
|
||||
async def test_returned_width_is_validated_and_not_retried():
|
||||
svc = _service(dims=8)
|
||||
client = MagicMock()
|
||||
client.embeddings.with_raw_response.create = AsyncMock(return_value=_fake_raw(2, 7))
|
||||
with patch("openai.AsyncOpenAI", return_value=client), \
|
||||
pytest.raises(EmbeddingDimensionMismatchError):
|
||||
await svc.embed_batch(["a", "b"], use_cache=False)
|
||||
assert client.embeddings.with_raw_response.create.await_count == 1
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Re-embed the Kuzu graph into a new embedding space.
|
||||
|
||||
The source graph is opened read-only and never modified. A new graph is built
|
||||
at the target path with the target width: every node and edge is copied, the
|
||||
embedding columns are recomputed from their text, the HNSW index is rebuilt.
|
||||
Runs in CI: kuzu is a core dependency.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory import (
|
||||
EmbeddingDimensionMismatchError,
|
||||
MemoryConfig,
|
||||
ReembedCancelled,
|
||||
drop_graph,
|
||||
reembed_graph,
|
||||
verify_graph_reembed,
|
||||
)
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryNode
|
||||
from hy_memory.reembed import hierarchy_topic_label, plan_graph_reembed
|
||||
|
||||
from .test_reembed import FakeEmbedder
|
||||
|
||||
|
||||
def _cfg(path, dims):
|
||||
cfg = MemoryConfig()
|
||||
cfg.graph_store.provider = "kuzu"
|
||||
cfg.graph_store.db_path = str(path)
|
||||
cfg.vector_store.embedding_dims = dims
|
||||
return cfg
|
||||
|
||||
|
||||
async def _open(path, dims):
|
||||
from hy_memory.data.graph_store_kuzu import KuzuGraphStore
|
||||
|
||||
store = KuzuGraphStore(_cfg(path, dims))
|
||||
await store.initialize()
|
||||
assert store._available
|
||||
return store
|
||||
|
||||
|
||||
def _node(content, *, layer=MemoryLayer.L2_FACT, custom=None, user="alice"):
|
||||
return MemoryNode(
|
||||
user_id=user, agent_id="a", session_id="s1",
|
||||
layer=layer, content=content, custom=custom or {},
|
||||
)
|
||||
|
||||
|
||||
async def _seed_graph(path, dims, model="old"):
|
||||
"""A graph with every node and edge type the SDK writes."""
|
||||
old = FakeEmbedder(model, dims)
|
||||
gs = await _open(path, dims)
|
||||
schema = _node(
|
||||
"画像:喜欢提前规划", layer=MemoryLayer.L6_SCHEMA,
|
||||
custom={"schema_type": "basic", "retrieval_text": "plans trips weeks ahead",
|
||||
"_has_beh": True, "beh_abstraction": "prepares early under uncertainty"},
|
||||
)
|
||||
schema._graph_embedding = old.vector("plans trips weeks ahead")
|
||||
core = _node("core: anticipates", layer=MemoryLayer.L6_SCHEMA, custom={"schema_type": "abstract"})
|
||||
core._graph_embedding = old.vector("core: anticipates")
|
||||
stale_beh = _node("beh without text", layer=MemoryLayer.L6_SCHEMA, custom={})
|
||||
stale_beh._graph_embedding = old.vector("beh without text")
|
||||
facts = [_node(f"fact {i}", user="bob" if i % 2 else "alice") for i in range(5)]
|
||||
for node in [schema, core, stale_beh, *facts]:
|
||||
await gs.upsert_memory_node(node)
|
||||
await gs.update_embedding(schema.node_id, beh_embedding=old.vector("prepares early under uncertainty"))
|
||||
await gs.update_embedding(stale_beh.node_id, beh_embedding=old.vector("lost text"))
|
||||
await gs.add_cross_abstracts_to(schema.node_id, core.node_id)
|
||||
ik = facts[0].get_isolation_key()
|
||||
await gs.add_edge(facts[0].node_id, facts[1].node_id, "RELATED_TO", {"relation_type": "co_occurs", "weight": 0.7})
|
||||
await gs.add_temporal_edge(facts[0].node_id, facts[2].node_id, topic_id="t")
|
||||
for fact in facts[:3]:
|
||||
await gs.add_topic_tag(fact.node_id, "travel", ik, embed_service=old)
|
||||
await gs.add_topic_tag(facts[3].node_id, "work", ik) # no embed service: NULL embedding
|
||||
await gs.add_topic_path(
|
||||
facts[0].node_id, ["Travel", "Japan trip"], ik, "alice",
|
||||
node_types=["topic", "series"], embed_service=old,
|
||||
)
|
||||
await gs.add_topic_path(facts[1].node_id, ["Work", "Standups"], ik, "alice", embed_service=old)
|
||||
await gs.add_entity_mention(facts[0].node_id, "Kyoto", "GPE", ik)
|
||||
await gs.ensure_vdbref("raw-1", "l1_raw")
|
||||
await gs.add_derived_from(schema.node_id, "raw-1")
|
||||
await gs.close()
|
||||
return old, {"schema": schema, "core": core, "stale_beh": stale_beh, "facts": facts}
|
||||
|
||||
|
||||
def _digest(path):
|
||||
"""Hash of the Kuzu DB file and any sidecar files next to it."""
|
||||
h = hashlib.sha256()
|
||||
for name in sorted(os.listdir(path.parent)):
|
||||
if name == path.name or name.startswith(path.name + "."):
|
||||
h.update(name.encode())
|
||||
with open(path.parent / name, "rb") as handle:
|
||||
h.update(handle.read())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _read(path, query):
|
||||
import kuzu
|
||||
|
||||
db = kuzu.Database(str(path), read_only=True)
|
||||
conn = kuzu.Connection(db)
|
||||
try:
|
||||
result = conn.execute(query)
|
||||
rows = []
|
||||
while result.has_next():
|
||||
rows.append(result.get_next())
|
||||
return rows
|
||||
finally:
|
||||
conn.close()
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"src_dims,tgt_dims,src_model,tgt_model",
|
||||
[(512, 512, "old", "new"), (512, 1024, "old", "new"), (1024, 512, "old", "new")],
|
||||
)
|
||||
async def test_reembed_graph_copies_everything_at_the_new_width(
|
||||
tmp_path, src_dims, tgt_dims, src_model, tgt_model,
|
||||
):
|
||||
source, target = tmp_path / "graph", tmp_path / "graph-new"
|
||||
_, nodes = await _seed_graph(source, src_dims, model=src_model)
|
||||
before = _digest(source)
|
||||
plan = await plan_graph_reembed(source, target_dims=tgt_dims)
|
||||
assert plan.source_width == src_dims and plan.target_width == tgt_dims
|
||||
assert plan.memory_nodes == 8 and plan.topics == 2 and plan.hierarchy_topics == 4
|
||||
assert plan.memory_embeddings == 3 and plan.beh_embeddings == 2
|
||||
assert plan.topic_embeddings == 1 and plan.hierarchy_topic_embeddings == 4
|
||||
|
||||
new = FakeEmbedder(tgt_model, tgt_dims)
|
||||
seen = []
|
||||
report = await reembed_graph(
|
||||
source, target, embedding=new, target_dims=tgt_dims, batch_size=3, on_progress=seen.append,
|
||||
)
|
||||
assert _digest(source) == before # the source graph is untouched
|
||||
assert not os.path.exists(str(target) + ".reembed-partial")
|
||||
assert report.source_width == src_dims and report.target_width == tgt_dims
|
||||
assert report.node_counts == plan.node_counts
|
||||
assert report.rel_counts == plan.rel_counts
|
||||
assert report.embedded == {
|
||||
"Memory.embedding": 3, "Memory.beh_embedding": 1,
|
||||
"Topic.embedding": 1, "HierarchyTopic.embedding": 4,
|
||||
}
|
||||
assert report.cleared == {"Memory.beh_embedding": 1} # beh vector with no beh_abstraction
|
||||
assert report.hierarchy_labels_exact == 4 and report.hierarchy_labels_inexact == ()
|
||||
assert report.vector_indexes == ("memory_content_idx",)
|
||||
assert seen
|
||||
|
||||
check = await verify_graph_reembed(source, target, target_dims=tgt_dims)
|
||||
assert check.ok, check.problems
|
||||
assert check.vector_index and check.target_width == tgt_dims
|
||||
assert all(src == dst for src, dst in check.node_counts.values())
|
||||
assert all(src == dst for src, dst in check.rel_counts.values())
|
||||
|
||||
schema = nodes["schema"]
|
||||
rows = _read(target, f"MATCH (m:Memory {{node_id: '{schema.node_id}'}}) RETURN m.embedding, m.beh_embedding, m.custom_json, m.content;")
|
||||
emb, beh, custom, content = rows[0]
|
||||
assert len(emb) == tgt_dims
|
||||
assert emb == pytest.approx(new.vector("plans trips weeks ahead"), abs=1e-5)
|
||||
assert beh == pytest.approx(new.vector("prepares early under uncertainty"), abs=1e-5)
|
||||
assert json.loads(custom)["retrieval_text"] == "plans trips weeks ahead" and content == schema.content
|
||||
rows = _read(target, f"MATCH (m:Memory {{node_id: '{nodes['core'].node_id}'}}) RETURN m.embedding;")
|
||||
assert rows[0][0] == pytest.approx(new.vector("core: anticipates"), abs=1e-5)
|
||||
rows = _read(target, f"MATCH (m:Memory {{node_id: '{nodes['facts'][0].node_id}'}}) RETURN m.embedding IS NULL;")
|
||||
assert rows[0][0] is True # a NULL source vector stays NULL
|
||||
topics = dict(_read(target, "MATCH (t:Topic) RETURN t.name, t.embedding;"))
|
||||
assert topics["travel"] == pytest.approx(new.vector("travel"), abs=1e-5)
|
||||
assert topics["work"] is None
|
||||
for subject, path_json, embedding in _read(target, "MATCH (t:HierarchyTopic) RETURN t.subject, t.path_json, t.embedding;"):
|
||||
label, exact = hierarchy_topic_label(subject, path_json)
|
||||
assert exact and embedding == pytest.approx(new.vector(label), abs=1e-5)
|
||||
|
||||
# The rebuilt graph serves the SDK: vector search on the new index works.
|
||||
gs = await _open(target, tgt_dims)
|
||||
try:
|
||||
hits = await gs.vector_search(
|
||||
query_embedding=new.vector("plans trips weeks ahead"),
|
||||
isolation_key=schema.get_isolation_key(), limit=1,
|
||||
)
|
||||
assert hits and hits[0]["node_id"] == schema.node_id
|
||||
width = await gs._run("CALL table_info('Memory') RETURN *;")
|
||||
assert dict((r[1], r[2]) for r in width)["embedding"] == f"FLOAT[{tgt_dims}]"
|
||||
finally:
|
||||
await gs.close()
|
||||
|
||||
|
||||
async def test_source_labels_rebuild_exactly(tmp_path):
|
||||
"""The stored HierarchyTopic vectors were computed from the rebuilt label."""
|
||||
source = tmp_path / "graph"
|
||||
old, _ = await _seed_graph(source, 64)
|
||||
rows = _read(source, "MATCH (t:HierarchyTopic) RETURN t.subject, t.path_json, t.embedding;")
|
||||
assert len(rows) == 4
|
||||
for subject, path_json, embedding in rows:
|
||||
label, exact = hierarchy_topic_label(subject, path_json)
|
||||
assert exact
|
||||
assert embedding == pytest.approx(old.vector(label), abs=1e-6)
|
||||
|
||||
|
||||
def test_legacy_path_json_is_flagged_inexact():
|
||||
label, exact = hierarchy_topic_label("alice", json.dumps(["Travel", "Japan"]), "Japan")
|
||||
assert label == "alice: topic:Travel > facet:Japan" and not exact
|
||||
label, exact = hierarchy_topic_label("alice", "", "Japan")
|
||||
assert label == "alice: Japan" and not exact
|
||||
|
||||
|
||||
async def test_cancel_and_failure_leave_no_target(tmp_path):
|
||||
source, target = tmp_path / "graph", tmp_path / "graph-new"
|
||||
await _seed_graph(source, 64)
|
||||
with pytest.raises(ReembedCancelled):
|
||||
await reembed_graph(
|
||||
source, target, embedding=FakeEmbedder("new", 32), target_dims=32,
|
||||
cancel=lambda: True,
|
||||
)
|
||||
with pytest.raises(EmbeddingDimensionMismatchError):
|
||||
await reembed_graph(
|
||||
source, target, embedding=FakeEmbedder("new", 32, wrong_width_after=1), target_dims=32,
|
||||
)
|
||||
assert not os.path.exists(target)
|
||||
assert not os.path.exists(str(target) + ".reembed-partial")
|
||||
# A restart starts over and succeeds.
|
||||
report = await reembed_graph(source, target, embedding=FakeEmbedder("new", 32), target_dims=32)
|
||||
assert report.node_counts["Memory"] == 8
|
||||
with pytest.raises(FileExistsError):
|
||||
await reembed_graph(source, target, embedding=FakeEmbedder("new", 32), target_dims=32)
|
||||
again = await reembed_graph(
|
||||
source, target, embedding=FakeEmbedder("new", 32), target_dims=32, overwrite=True,
|
||||
)
|
||||
assert again.rel_counts == report.rel_counts
|
||||
|
||||
|
||||
async def test_refuses_open_source_and_drops_only_closed_graphs(tmp_path):
|
||||
source, target = tmp_path / "graph", tmp_path / "graph-new"
|
||||
await _seed_graph(source, 64)
|
||||
live = await _open(source, 64)
|
||||
try:
|
||||
with pytest.raises(RuntimeError):
|
||||
await reembed_graph(source, target, embedding=FakeEmbedder("new", 64), target_dims=64)
|
||||
with pytest.raises(RuntimeError):
|
||||
await drop_graph(source)
|
||||
finally:
|
||||
await live.close()
|
||||
with pytest.raises(ValueError):
|
||||
await reembed_graph(source, source, embedding=FakeEmbedder("new", 64), target_dims=64)
|
||||
await reembed_graph(source, target, embedding=FakeEmbedder("new", 64), target_dims=64)
|
||||
assert await drop_graph(target) is True
|
||||
assert not os.path.exists(target)
|
||||
assert await drop_graph(target) is False
|
||||
assert os.path.exists(source)
|
||||
Reference in new issue
Block a user