Compare commits

..
Author SHA1 Message Date
alvinfeiandCursor 1821a31295 fix(core): drop native store write gate on HY search
MemHub expects concurrent native reads during precipitation. The
process-local read slot mostly existed for a Chroma/GIL fail-fast and
was never held by writers on main; activating it (as in #98) would only
push recall onto weaker fallbacks. Remove the lock module and the
hy_memory_store_busy path instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 12:07:37 +08:00
68 changed files with 952 additions and 3586 deletions

No files matched your search

-1
View File
@@ -86,7 +86,6 @@ MEMORY_VECTOR_STORE=chroma
# MEMORY_CODING_DB_PATH= # coding_memory.db 路径(默认 {DATA_DIR}/data/coding_memory.db)
# MEMORY_CODING_TOOL_RESULT_MAX_BYTES=2048 # 单条 tool_result 截断字节上限
# MEMORY_CODING_VERBATIM_FALLBACK_ENABLED=false # 模型无结果时是否保存原文 fact;默认关,避免 tool trace 外泄
# MEMORY_CODING_CHAIN_BLOCK_ENABLED=false # search 是否注入版本演进链块(diff);默认关,省 token
# MEMORY_EXTRACT_HISTORY_TURNS=5 # 除本次 write 外,额外注入 extractor 的历史 user-turn 数
# ── 写入流程开关 ──
+7 -13
View File
@@ -122,25 +122,19 @@ jobs:
shell: bash
run: |
set -euo pipefail
shopt -s globstar nullglob
version=$(node -p 'require("./package.json").version')
node scripts/verify-release-version.mjs \
--channel stable \
--version "$version" \
--build-number "$(node -p 'require("./package.json").release.build_number')" \
--tag "$GITHUB_REF_NAME"
mkdir -p publish-assets
for arch in x64 arm64; do
name="memhub-runtime-v${version}-linux-${arch}.tar.gz"
archives=(release-assets/**/"$name")
checksums=(release-assets/**/"$name.sha256")
test "${#archives[@]}" -eq 1
test "${#checksums[@]}" -eq 1
cp -- "${archives[0]}" "publish-assets/$name"
cp -- "${checksums[0]}" "publish-assets/$name.sha256"
archive="release-assets/memhub-runtime-v${version}-linux-${arch}.tar.gz"
test -f "$archive"
test -f "$archive.sha256"
(
cd publish-assets
sha256sum -c "$name.sha256"
cd release-assets
sha256sum -c "$(basename "$archive.sha256")"
)
done
- name: Upload Linux assets to a draft GitHub Release
@@ -166,7 +160,7 @@ jobs:
--title "$GITHUB_REF_NAME"
fi
gh release upload "$GITHUB_REF_NAME" \
publish-assets/memhub-runtime-v*-linux-*.tar.gz \
publish-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
release-assets/memhub-runtime-v*-linux-*.tar.gz \
release-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
--repo "$GITHUB_REPOSITORY" \
--clobber
-50
View File
@@ -2,40 +2,6 @@
Hy Memory and MemHub are versioned and released independently.
## Hy Memory 2.0.5.dev4 — 2026-10-21
- Development preview; not the final `2.0.5` release.
- Adds idempotent `process_raw` / `async_process_raw` APIs that extract and
reconcile ordered existing L1 raw IDs without embedding or inserting raw
again.
- Returns durable raw/memory ID receipts for MemHub queue recovery.
## Hy Memory 2.0.5.dev3 — 2026-10-21
- Development preview; not the final `2.0.5` release.
- Published as
[`hy-memory==2.0.5.dev3`](https://pypi.org/project/hy-memory/2.0.5.dev3/).
- Lets the existing `search` API explicitly query SDK raw with
`layers=["l1_raw"]`, including existing source filters.
## Hy Memory 2.0.5.dev2 — 2026-10-21
- Development preview; not the final `2.0.5` release.
- Published as
[`hy-memory==2.0.5.dev2`](https://pypi.org/project/hy-memory/2.0.5.dev2/).
- Adds exact, idempotent semantic-memory import for legacy MemHub cutover
without extraction, reconciliation or identity changes.
## Hy Memory 2.0.5.dev1 — 2026-10-21
- Development preview for the MemHub SDK-authority migration; this is not the
final `2.0.5` release.
- Published as
[`hy-memory==2.0.5.dev1`](https://pypi.org/project/hy-memory/2.0.5.dev1/).
- Adds lifecycle status filtering, durable idempotent write outcomes, exact raw
APIs, offline local data-plane snapshot/restore, and fail-closed physical
delete receipts.
## Hy Memory 2.0.3 — 2026-10-17
- Added an optional runtime HTTP transport factory so embedded products can
@@ -48,22 +14,6 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- `search(..., layers=["l1_raw"])` now explicitly searches SDK raw records in
pro/ultra mode and keeps existing user/agent/session/source filters; default
search continues to hide raw.
- Added `import_memory_exact` for idempotent migration of an existing semantic
memory with its canonical ID, lifecycle and supersession links, without
extraction or reconciliation.
- Added SDK-authoritative lifecycle listing with explicit status filters,
idempotent durable write outcomes, exact raw import/get/list APIs, and
quiesced local data-plane snapshot/restore contracts for the MemHub
authority migration.
- Physical delete now reports incomplete VDB/Graph cleanup as a partial failure
instead of claiming success after only one store was changed.
- Coding search no longer injects the version-evolution diff block
(`chain_block`) by default. Enable with
`MEMORY_CODING_CHAIN_BLOCK_ENABLED=true` or
`CodingConfig.chain_block_enabled=True` (write-side observe unchanged).
- Fixed governed-memory revisions and repeated evidence links failing when the
visibility dirty-marker trigger encounters the same memory twice. Startup
now replaces the affected legacy trigger without changing schema versions
+8 -8
View File
@@ -65,13 +65,13 @@ Welcome to the **Hy Memory Research Family**, covering cognitive chat memory, lo
### 1. Installation
```bash
pip install hy-memory==2.0.4 # core (includes ChromaDB and Kuzu)
pip install "hy-memory[qdrant]==2.0.4" # + Qdrant
pip install "hy-memory[faiss]==2.0.4" # + FAISS
pip install "hy-memory[graph]==2.0.4" # + Neo4j
pip install "hy-memory[mysql]==2.0.4" # + MySQL cache
pip install "hy-memory[rerank]==2.0.4" # + local multilingual cross-encoder rerank
pip install "hy-memory[all]==2.0.4" # everything
pip install hy-memory==2.0.3 # core (includes ChromaDB and Kuzu)
pip install "hy-memory[qdrant]==2.0.3" # + Qdrant
pip install "hy-memory[faiss]==2.0.3" # + FAISS
pip install "hy-memory[graph]==2.0.3" # + Neo4j
pip install "hy-memory[mysql]==2.0.3" # + MySQL cache
pip install "hy-memory[rerank]==2.0.3" # + local multilingual cross-encoder rerank
pip install "hy-memory[all]==2.0.3" # everything
```
### 2. Configuration
@@ -153,7 +153,7 @@ result = client.search(
result["facts"] # fact layer
result["experiences"] # experience layer
result["chain_block"] # coding only: version-evolution context (empty unless MEMORY_CODING_CHAIN_BLOCK_ENABLED)
result["chain_block"] # coding only: version-evolution context
```
### 5. Routing, Source Isolation & Advanced APIs
+8 -8
View File
@@ -65,13 +65,13 @@
### 1. 安装
```bash
pip install hy-memory==2.0.4 # 核心(含 ChromaDB 与 Kuzu)
pip install "hy-memory[qdrant]==2.0.4" # + Qdrant
pip install "hy-memory[faiss]==2.0.4" # + FAISS
pip install "hy-memory[graph]==2.0.4" # + Neo4j
pip install "hy-memory[mysql]==2.0.4" # + MySQL 缓存
pip install "hy-memory[rerank]==2.0.4" # + 本地多语言 cross-encoder rerank
pip install "hy-memory[all]==2.0.4" # 全部
pip install hy-memory==2.0.3 # 核心(含 ChromaDB 与 Kuzu)
pip install "hy-memory[qdrant]==2.0.3" # + Qdrant
pip install "hy-memory[faiss]==2.0.3" # + FAISS
pip install "hy-memory[graph]==2.0.3" # + Neo4j
pip install "hy-memory[mysql]==2.0.3" # + MySQL 缓存
pip install "hy-memory[rerank]==2.0.3" # + 本地多语言 cross-encoder rerank
pip install "hy-memory[all]==2.0.3" # 全部
```
### 2. 配置
@@ -153,7 +153,7 @@ result = client.search(
result["facts"] # 事实层
result["experiences"] # 经验层
result["chain_block"] # 仅 coding:版本演进上下文(默认空;需 MEMORY_CODING_CHAIN_BLOCK_ENABLED)
result["chain_block"] # 仅 coding:版本演进上下文
```
### 5. 自动路由、来源过滤与进阶 API
-1
View File
@@ -197,7 +197,6 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
| `MEMORY_HISTORY_DB_PATH` | `{DATA_DIR}/data/history.db` | History SQLite 路径 |
| `MEMORY_HISTORY_RECORD_SEARCHES` | `false` | 是否记录 Search 操作 |
| `MEMORY_CODING_CHAIN_REPO_ROOT` | — | coding 版本链读取本地 Git diff 的仓库根目录 |
| `MEMORY_CODING_CHAIN_BLOCK_ENABLED` | `false` | search(scene=coding) 是否注入版本演进链块(diff / `chain_block`);默认关 |
## HTTP Server
+2 -3
View File
@@ -78,8 +78,7 @@ default 多-query 超过 `MEMORY_QUERY_REWRITE_MAX_CHARS` 时不改写:Coding
Coding 关闭则直接使用最后一条 query。
路由到 `working` / `coding` 后,Search 读取独立生产力记忆库,返回 `facts` /
`experiences`,不使用 chat 三通道结构。`coding` 还返回 `chain_block` / `chain_used`
(默认空;需 `MEMORY_CODING_CHAIN_BLOCK_ENABLED` 才注入 diff 块)。
`experiences`,不使用 chat 三通道结构。`coding` 还返回 `chain_block` / `chain_used`。
## Chat 成功返回结构
@@ -153,7 +152,7 @@ client = HyMemoryClient.from_config({
安装可选依赖:
```bash
pip install 'hy-memory[rerank]==2.0.4'
pip install 'hy-memory[rerank]==2.0.1'
```
启用后,`HyMemoryClient(...)` 构造阶段会完成模型下载/读取、device 放置和一次 dummy
+3 -2
View File
@@ -37,7 +37,8 @@ do not belong in the public source tree.
- Root GitHub workflows run SDK and MemHub checks from their respective working
directories. Native lifecycle workflows produce reviewable Actions artifacts;
tag releases remain fail-closed on public identity, signing and notarization.
- Hy Memory is published as `hy-memory==2.0.4`. MemHub pins that public
distribution; its selected npm package is `memhub-cli` 1.0.5.
- Hy Memory is published as `hy-memory==2.0.3`. MemHub pins that public
distribution; its selected npm package is `memhub-cli`, which is not yet
published.
- Hy-Memory source uses Apache-2.0 at the repository root. Third-party
components retain their own licenses and attribution notices.
+2 -6
View File
@@ -168,11 +168,9 @@ working/coding 搜索返回:
`limit` 分别限制 facts 与 experiences,因此两层最多各返回 `limit` 条。
`scene="coding"` 额外返回 `chain_block` 和 `chain_used` 字段(键始终存在;默认内容为空):
`scene="coding"` 额外返回 `chain_block` 和 `chain_used`:
- 注入 diff / 版本演进块需显式开启 `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true`
(或 `CodingConfig.chain_block_enabled=True`)。默认关闭,以节约 prompt token。
- 开启后,`chain_block` 是与已召回 fact 对齐的版本演进上下文;无法挂链时为 `""`。
- `chain_block` 是与已召回 fact 对齐的版本演进上下文;无法挂链时为 `""`。
- `chain_used` 是已经进入 chain block 的 `facts` 下标。
- SDK 不会从 `facts` 删除这些条目。调用方若要拼成“chain block + 剩余 facts”,需自行按 `chain_used` 过滤。
@@ -224,12 +222,10 @@ working/coding 搜索返回:
| `MEMORY_CODING_DB_PATH` | `{DATA_DIR}/data/coding_memory.db` | working 基础 SQLite 路径;coding/trace 路径由此派生 |
| `MEMORY_CODING_TOOL_RESULT_MAX_BYTES` | `2048` | 单条 tool result 的字节上限 |
| `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` | `false` | 模型无结果时是否保存原文 fact;默认关闭,避免 assistant/tool trace 直接展示 |
| `MEMORY_CODING_CHAIN_BLOCK_ENABLED` | `false` | search 是否注入版本演进链块(`chain_block` / diff);默认关闭(opt-in) |
| `MEMORY_EXTRACT_HISTORY_TURNS` | `5` | 回读的历史 user-turn 数;当前 prompt 最终最多使用 5 |
| `MEMORY_CODING_CHAIN_REPO_ROOT` | 空 | coding 版本链读取本地 Git diff 的仓库路径 |
`HCM_*` 变量属于版本链的高级调优项,不是稳定的公共配置契约;普通接入不应依赖其默认值。
其中 `HCM_BLOCK_OFF=1` 可在已开启 `MEMORY_CODING_CHAIN_BLOCK_ENABLED` 时做消融(完全不出块)。
## 8. 定向验证
+7 -8
View File
@@ -5,14 +5,14 @@
## 安装与快速开始
```bash
pip install hy-memory==2.0.4
pip install hy-memory==2.0.3
# 可选后端
pip install 'hy-memory[qdrant]==2.0.4'
pip install 'hy-memory[faiss]==2.0.4'
pip install 'hy-memory[graph]==2.0.4' # Neo4j;Kuzu 已在核心依赖中
pip install 'hy-memory[mysql]==2.0.4'
pip install 'hy-memory[rerank]==2.0.4' # 本地多语言 cross-encoder rerank
pip install 'hy-memory[qdrant]==2.0.1'
pip install 'hy-memory[faiss]==2.0.1'
pip install 'hy-memory[graph]==2.0.1' # Neo4j;Kuzu 已在核心依赖中
pip install 'hy-memory[mysql]==2.0.1'
pip install 'hy-memory[rerank]==2.0.1' # 本地多语言 cross-encoder rerank
```
默认 LLM 和 Embedding 均使用 OpenAI-compatible 接口。使用 OpenAI 时可只设置:
@@ -210,8 +210,7 @@ result = client.search(
```
`working` / `coding` 路径返回 `scene="productivity"`、`facts`、`experiences`,不使用
chat 的三桶结构;`coding` 还会返回 `chain_block` 和 `chain_used` 字段(默认空;
需 `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true` 才注入 diff 块)。
chat 的三桶结构;`coding` 还会返回 `chain_block` 和 `chain_used`。
#### `search_mode`
-12
View File
@@ -55,13 +55,6 @@ except Exception:
from .client import HyMemoryClient
from .inspector import MemoryInspector
from .runtime import SharedRuntime
from .snapshot import (
DataPlaneRestore,
DataPlaneSnapshot,
DataPlaneSnapshotRejected,
restore_local_data_plane,
snapshot_local_data_plane,
)
# ====== 配置 ======
from .config import MemoryConfig
@@ -118,11 +111,6 @@ __all__ = [
"HyMemoryClient",
"MemoryInspector",
"SharedRuntime",
"DataPlaneRestore",
"DataPlaneSnapshot",
"DataPlaneSnapshotRejected",
"restore_local_data_plane",
"snapshot_local_data_plane",
# 配置
"MemoryConfig",
# Embedding 端点覆盖
+1 -2
View File
@@ -22,8 +22,7 @@ logger = logging.getLogger(__name__)
# ================================================================
# 数据结构
# ================================================================
@dataclass
# ================================================================@dataclass
class SessionSummaryResult:
"""Session 摘要结果 (V2)"""
success: bool
+3 -13
View File
@@ -39,7 +39,6 @@ import logging
import hashlib
import json
import re
from datetime import datetime
from typing import Any, Dict, List, Optional
import uuid
@@ -253,17 +252,6 @@ def _series_episode_id(
return str(uuid.uuid5(uuid.NAMESPACE_URL, "hy-memory:series-episode:" + raw))
def _series_memory_at(times: List[int]) -> Optional[datetime]:
"""Earliest member timestamp as the same local wall clock it came from.
``_node_timestamp`` produces ``datetime.timestamp()`` values. Reading them
back with ``utcfromtimestamp`` turns them into a naive *UTC* datetime, which
the vector payload later re-interprets as local time and shifts by the
machine's UTC offset.
"""
return datetime.fromtimestamp(min(times)) if times else None
def _node_timestamp(node: Any) -> Optional[int]:
value = getattr(node, "memory_at", None) or getattr(node, "gmt_created", None)
if value is None:
@@ -986,6 +974,8 @@ async def finalize_series_episode_containers(
Stored text is only a title plus a structural intro. Exact L2 bodies remain in
their canonical nodes and are referenced by ID from dated timeline segments.
"""
from datetime import datetime
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus, SourceType
from ..pipelines._retrieval.session_episode import (
add_parent_episode_membership,
@@ -1071,7 +1061,7 @@ async def finalize_series_episode_containers(
segment["memory_at"] for segment in plan["timeline"]
if segment.get("memory_at") is not None
]
memory_at = _series_memory_at(times)
memory_at = datetime.utcfromtimestamp(min(times)) if times else None
content = f"{plan['title']}. {plan['intro']}".strip()
series = MemoryNode(
node_id=series_id, user_id=user_id,
+109 -1070
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -647,9 +647,7 @@ class HyCodeMem:
top-k,但它们的 diff 对当前工作区不可能有用,因此挂链前必须做同仓过滤。
"""
facts = facts or []
# 高级消融开关: HCM_BLOCK_OFF=1 → 完全不出块, 也不从 bullet 删任何 fact。
# 公共 opt-in 见 CodingConfig.chain_block_enabled /
# MEMORY_CODING_CHAIN_BLOCK_ENABLED(默认关;客户端在调用 decorate 前拦截)。
# 开关: HCM_BLOCK_OFF=1 → 完全不出块, 也不从 bullet 删任何 fact。
if os.environ.get("HCM_BLOCK_OFF") == "1":
return "", []
picked, used = [], []
-7
View File
@@ -1136,9 +1136,6 @@ class CodingConfig:
# LLM 失败或没有抽出 fact 时,是否把对话原文存成 working fact。
# 默认关闭,避免 assistant/tool 轨迹以 statement 形式直接暴露给消费方。
verbatim_fallback_enabled: bool = None
# search(scene="coding") 是否注入版本演进链块(diff / chain_block)。
# 默认关闭(opt-in):中重度 coding 用户可省下大量 prompt token;写入侧仍可 observe。
chain_block_enabled: bool = None
def __post_init__(self):
if self.enable is None:
@@ -1156,10 +1153,6 @@ class CodingConfig:
self.verbatim_fallback_enabled = _get_env_bool(
"MEMORY_CODING_VERBATIM_FALLBACK_ENABLED", False
)
if self.chain_block_enabled is None:
self.chain_block_enabled = _get_env_bool(
"MEMORY_CODING_CHAIN_BLOCK_ENABLED", False
)
@dataclass
-31
View File
@@ -114,37 +114,6 @@ class CacheBase(ABC):
"""查询 pipeline 中间结果日志"""
...
# ================================================================
# Durable write outcomes
# ================================================================
@abstractmethod
async def claim_write_outcome(
self,
request_id: str,
user_id: str,
agent_id: str,
session_id: str,
operation: str,
) -> Optional[Dict[str, Any]]:
"""Claim one idempotent write; return the existing row if already claimed."""
...
@abstractmethod
async def finish_write_outcome(
self,
request_id: str,
status: str,
result: Dict[str, Any],
) -> bool:
"""Persist the terminal/unknown result for a claimed write."""
...
@abstractmethod
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
"""Read one durable write outcome by exact request id."""
...
# ================================================================
# Summary Buffer(rolling summary 的未消费 turn 暂存)
#
+16 -130
View File
@@ -81,21 +81,6 @@ _CREATE_TABLES_SQL = [
)
""",
"""
CREATE TABLE IF NOT EXISTS write_outcomes (
request_id VARCHAR(256) NOT NULL,
user_id VARCHAR(255) NOT NULL,
agent_id VARCHAR(255) NOT NULL DEFAULT '',
session_id VARCHAR(255) NOT NULL DEFAULT '',
operation VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
result_json LONGTEXT NOT NULL,
created_at VARCHAR(64) NOT NULL,
updated_at VARCHAR(64) NOT NULL,
PRIMARY KEY (request_id),
KEY idx_write_outcomes_owner (user_id, agent_id, session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS system_metrics (
minute_ts VARCHAR(32) NOT NULL,
data LONGTEXT NOT NULL,
@@ -474,128 +459,29 @@ class MysqlCache(CacheBase):
logger.warning(f"get_pipeline_logs failed: {e}")
return []
async def claim_write_outcome(
self,
request_id: str,
user_id: str,
agent_id: str,
session_id: str,
operation: str,
) -> Optional[Dict[str, Any]]:
now = datetime.now().isoformat()
inserted = await self._execute(
"""
INSERT IGNORE INTO write_outcomes (
request_id, user_id, agent_id, session_id, operation,
status, result_json, created_at, updated_at
) VALUES (%s, %s, %s, %s, %s, 'running', '{}', %s, %s)
""",
(request_id, user_id, agent_id, session_id, operation, now, now),
)
if inserted == 1:
return None
return await self.get_write_outcome(request_id)
async def finish_write_outcome(
self,
request_id: str,
status: str,
result: Dict[str, Any],
) -> bool:
updated = await self._execute(
"""
UPDATE write_outcomes
SET status = %s, result_json = %s, updated_at = %s
WHERE request_id = %s
""",
(
status,
json.dumps(result, ensure_ascii=False, default=str),
datetime.now().isoformat(),
request_id,
),
)
return updated == 1
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
row = await self._fetchone(
"SELECT * FROM write_outcomes WHERE request_id = %s",
(request_id,),
)
if row is None:
return None
try:
row["result"] = json.loads(row.pop("result_json") or "{}")
except (TypeError, json.JSONDecodeError):
row["result"] = {}
return row
# ================================================================
# System Metrics(分钟粒度落盘)
# ================================================================
async def store_metrics_minute(self, minute_ts: str, data: dict) -> None:
import aiomysql
try:
now = datetime.now().isoformat()
# Read-merge-write must be one transaction: two instances flushing
# the same minute used to overwrite each other's counters, and a
# concurrent insert could lose the whole batch to a duplicate key.
for attempt in range(2):
try:
async with self._pool.acquire() as conn:
await conn.begin()
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT data FROM system_metrics "
"WHERE minute_ts = %s FOR UPDATE",
(minute_ts,),
)
row = await cur.fetchone()
if row is not None:
old_raw = (
row["data"]
if isinstance(row, dict)
else row[0]
)
merged = self._merge_metric_buckets(
json.loads(old_raw), data,
)
await cur.execute(
"UPDATE system_metrics SET data = %s, "
"created_at = %s WHERE minute_ts = %s",
(
json.dumps(merged, default=str),
now,
minute_ts,
),
)
else:
await cur.execute(
"INSERT INTO system_metrics "
"(minute_ts, data, created_at) "
"VALUES (%s, %s, %s)",
(
minute_ts,
json.dumps(data, default=str),
now,
),
)
await conn.commit()
return
except Exception:
await conn.rollback()
raise
except (aiomysql.IntegrityError, aiomysql.OperationalError) as error:
code = error.args[0] if error.args else None
deadlock = code in (1205, 1213)
if attempt or (isinstance(error, aiomysql.OperationalError) and not deadlock):
raise
# A competing writer inserted the row, or InnoDB chose us
# as the deadlock victim. Retry so this batch merges.
continue
# 尝试 UPSERT
existing = await self._fetchone(
"SELECT data FROM system_metrics WHERE minute_ts = %s", (minute_ts,)
)
if existing:
old_data = json.loads(existing["data"])
merged = self._merge_metric_buckets(old_data, data)
await self._execute(
"UPDATE system_metrics SET data = %s, created_at = %s WHERE minute_ts = %s",
(json.dumps(merged, default=str), now, minute_ts),
)
else:
await self._execute(
"INSERT INTO system_metrics (minute_ts, data, created_at) VALUES (%s, %s, %s)",
(minute_ts, json.dumps(data, default=str), now),
)
except Exception as e:
logger.warning(f"store_metrics_minute failed: {e}")
-96
View File
@@ -87,20 +87,6 @@ CREATE TABLE IF NOT EXISTS pipeline_logs_v2 (
CREATE INDEX IF NOT EXISTS idx_plog_request ON pipeline_logs_v2(request_id);
CREATE INDEX IF NOT EXISTS idx_plog_user ON pipeline_logs_v2(user_id);
CREATE TABLE IF NOT EXISTS write_outcomes (
request_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL DEFAULT '',
operation TEXT NOT NULL,
status TEXT NOT NULL,
result_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_write_outcomes_owner
ON write_outcomes(user_id, agent_id, session_id);
CREATE TABLE IF NOT EXISTS system_metrics (
minute_ts TEXT PRIMARY KEY,
data TEXT NOT NULL,
@@ -428,88 +414,6 @@ class SqliteCache(CacheBase):
logger.warning(f"get_pipeline_logs failed: {e}")
return []
# ================================================================
# Durable write outcomes
# ================================================================
async def claim_write_outcome(
self,
request_id: str,
user_id: str,
agent_id: str,
session_id: str,
operation: str,
) -> Optional[Dict[str, Any]]:
def _claim():
with self._lock:
now = datetime.now().isoformat()
cursor = self._conn.execute(
"""
INSERT OR IGNORE INTO write_outcomes (
request_id, user_id, agent_id, session_id, operation,
status, result_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'running', '{}', ?, ?)
""",
(request_id, user_id, agent_id, session_id, operation, now, now),
)
self._conn.commit()
if cursor.rowcount == 1:
return None
row = self._conn.execute(
"SELECT * FROM write_outcomes WHERE request_id = ?",
(request_id,),
).fetchone()
return dict(row) if row else None
existing = await _run_in_sqlite_pool(_claim)
if existing is None:
return None
return await self.get_write_outcome(request_id)
async def finish_write_outcome(
self,
request_id: str,
status: str,
result: Dict[str, Any],
) -> bool:
def _finish():
with self._lock:
cursor = self._conn.execute(
"""
UPDATE write_outcomes
SET status = ?, result_json = ?, updated_at = ?
WHERE request_id = ?
""",
(
status,
json.dumps(result, ensure_ascii=False, default=str),
datetime.now().isoformat(),
request_id,
),
)
self._conn.commit()
return cursor.rowcount == 1
return await _run_in_sqlite_pool(_finish)
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
def _get():
with self._lock:
row = self._conn.execute(
"SELECT * FROM write_outcomes WHERE request_id = ?",
(request_id,),
).fetchone()
return dict(row) if row else None
row = await _run_in_sqlite_pool(_get)
if row is None:
return None
try:
row["result"] = json.loads(row.pop("result_json") or "{}")
except (TypeError, json.JSONDecodeError):
row["result"] = {}
return row
# ================================================================
# System Metrics(分钟粒度落盘)
# ================================================================
-33
View File
@@ -66,11 +66,6 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _escape_like(value: str) -> str:
"""Escape LIKE wildcards so a user id is matched literally."""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
class HistoryStore:
"""
SQLite 历史存储 - 记录所有内存操作的审计日志。
@@ -266,34 +261,6 @@ class HistoryStore:
)
return [dict(row) for row in cursor.fetchall()]
async def get_recent_for_user(
self,
user_id: str,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""获取某 user 下所有 agent 的最近操作。
isolation_key 形如 ``"{user_id}:{agent_id}"``。user_id 里的 LIKE
通配符会被转义,保证只匹配这个 user,不会漏到 ``u1`` 之外。
"""
prefix = _escape_like(f"{user_id}:") + "%"
return await _run_in_sqlite_pool(
self._get_recent_prefix_sync, prefix, limit,
)
def _get_recent_prefix_sync(
self, pattern: str, limit: int,
) -> List[Dict[str, Any]]:
with self._conn_lock:
if self._conn is None:
return []
cursor = self._conn.execute(
"SELECT * FROM memory_history WHERE isolation_key LIKE ? ESCAPE '\\' "
"ORDER BY created_at DESC LIMIT ?",
(pattern, limit),
)
return [dict(row) for row in cursor.fetchall()]
async def count(self, isolation_key: str = "") -> int:
"""
统计历史记录数量。
+8 -33
View File
@@ -462,39 +462,14 @@ class FaissVectorStore(VectorStoreBase):
return None
async def get_embeddings(self, node_ids: List[str]) -> Dict[str, List[float]]:
"""批量取向量,供去重 / episode 摘要计算 cosine。
``_data`` 只保存 payload 与 FAISS 内部下标;普通 upsert 不额外存一份
embedding,向量本体在 FAISS index 里。这里按当前有效下标 reconstruct,
并用 ``_idx_to_id`` 校验,避免读到已被替换的旧下标。
"""
def _get() -> Dict[str, List[float]]:
out: Dict[str, List[float]] = {}
with self._lock:
for nid in node_ids:
point_id = self._node_id_to_point_id(nid)
entry = self._data.get(point_id)
if entry is None:
continue
embedding = entry.get("embedding")
if embedding:
out[nid] = list(embedding)
continue
idx = entry.get("idx")
if (
idx is None
or self._index is None
or self._idx_to_id.get(int(idx)) != point_id
):
continue
try:
out[nid] = self._index.reconstruct(int(idx)).tolist()
except Exception:
continue
return out
return await _run_in_vdb_pool(_get)
"""批量取向量(in-memory _data 直接读 embedding),供去重算 cosine。"""
out: Dict[str, List[float]] = {}
for nid in node_ids:
point_id = self._node_id_to_point_id(nid)
entry = self._data.get(point_id)
if entry and entry.get("embedding"):
out[nid] = list(entry["embedding"])
return out
# ================================================================
# 删除
-4
View File
@@ -231,10 +231,6 @@ class WriteRequest:
# 已有记忆 (V1 合并检测用)
existing_memories: Optional[List[Dict[str, Any]]] = None
# Public client process_raw() only: reuse these exact L1 nodes as input.
# Ordinary add() never populates this field.
existing_raw_ids: List[str] = field(default_factory=list)
# 记忆时间戳(不传则用当前时间 datetime.now())
memory_at: Optional[datetime] = None
+32 -86
View File
@@ -280,27 +280,6 @@ class MemoryWriter(WritePipeline):
custom["source_raw_memory_ids"] = source_ids
return custom
async def _attach_existing_raw_ids(
self,
vector_store,
memory_ids: List[str],
raw_ids: List[str],
) -> None:
"""Attach every reused raw record to canonical outputs."""
for memory_id in memory_ids:
node = await vector_store.get_by_id(memory_id)
if node is None:
continue
custom = dict(node.custom or {})
source_ids = list(custom.get("source_raw_memory_ids") or [])
legacy_source = str(getattr(node, "source_raw_memory_id", "") or "")
for raw_id in (legacy_source, *raw_ids):
if raw_id and raw_id not in source_ids:
source_ids.append(raw_id)
if source_ids:
custom["source_raw_memory_ids"] = source_ids
await vector_store.update_payload(memory_id, {"custom": custom})
@staticmethod
def _attach_client_meta(custom: Dict[str, Any], request: Optional[WriteRequest]) -> None:
"""给新建节点挂上调用方 meta(单份 list)。
@@ -989,7 +968,7 @@ class MemoryWriter(WritePipeline):
async def _run():
try:
nid = await summarize_closed_episode(
vector_store=await self._get_vector_store(),
vector_store=self.vector_store,
embed_service=self.embed_service,
llm_provider=self.llm_provider,
episode_id=episode_id,
@@ -2617,23 +2596,13 @@ class MemoryWriter(WritePipeline):
from ..metrics import MetricsCollector
MetricsCollector.get().sys1_start()
existing_raw_ids = request.existing_raw_ids
if (
not isinstance(existing_raw_ids, list)
or any(not isinstance(raw_id, str) or not raw_id for raw_id in existing_raw_ids)
):
raise ValueError("_existing_raw_ids must be a list of non-empty ids")
# 2. 向量化(处理已有 raw 时复用已存节点,不再次 embed)
# 2. 向量化 + 持久化(L1_RAW)
_t_embed = datetime.now()
if existing_raw_ids:
embedding, embed_meta = [], {"reused_raw": True}
else:
with tracer.span("embed") as s:
embedding, embed_meta = await self.embed_service.embed_queued_with_meta(
request.content
)
s.set_output({"dims": len(embedding), "content_len": len(request.content)})
with tracer.span("embed") as s:
embedding, embed_meta = await self.embed_service.embed_queued_with_meta(
request.content
)
s.set_output({"dims": len(embedding), "content_len": len(request.content)})
_embed_ms = (datetime.now() - _t_embed).total_seconds() * 1000
response.extra["embedding"] = embedding
await self._emit_pipeline_step(
@@ -2656,43 +2625,27 @@ class MemoryWriter(WritePipeline):
with tracer.span("qdrant_upsert") as s:
try:
vector_store = await self._get_vector_store()
if existing_raw_ids:
memory_id = existing_raw_ids[-1]
mem_node = await vector_store.get_by_id(memory_id)
if (
mem_node is None
or mem_node.layer != MemoryLayer.L1_RAW
or mem_node.user_id != request.user_id
or mem_node.agent_id != (request.agent_id or "default_agent")
or mem_node.session_id != (request.session_id or "default_session")
or mem_node.source != request.source
):
raise ValueError("existing raw record is outside the write scope")
else:
layer_enum = MemoryLayer.from_string(suggested_layer)
_raw_custom: Dict[str, Any] = {}
self._attach_client_meta(_raw_custom, request)
mem_node = MemoryNode(
user_id=request.user_id,
agent_id=request.agent_id or "default_agent",
session_id=request.session_id or "default_session",
source=request.source,
layer=layer_enum,
content=request.content,
source_type=SourceType.EXPLICIT,
status=MemoryStatus.ACTIVE,
is_latest=True,
embedding=embedding,
memory_at=request.memory_at,
custom=_raw_custom,
)
memory_id = await vector_store.upsert(mem_node)
layer_enum = MemoryLayer.from_string(suggested_layer)
_raw_custom: Dict[str, Any] = {}
self._attach_client_meta(_raw_custom, request)
mem_node = MemoryNode(
user_id=request.user_id,
agent_id=request.agent_id or "default_agent",
session_id=request.session_id or "default_session",
source=request.source,
layer=layer_enum,
content=request.content,
source_type=SourceType.EXPLICIT,
status=MemoryStatus.ACTIVE,
is_latest=True,
embedding=embedding,
memory_at=request.memory_at,
custom=_raw_custom,
)
memory_id = await vector_store.upsert(mem_node)
response.memory_id = memory_id
s.set_output({"memory_id": memory_id, "layer": suggested_layer})
logger.debug(
f"[write] {'reused' if existing_raw_ids else 'persisted'}: "
f"id={memory_id} layer={suggested_layer}"
)
logger.debug(f"[write] persisted: id={memory_id} layer={suggested_layer}")
except Exception as persist_err:
_l1_error = str(persist_err)
s.set_error(_l1_error)
@@ -2756,12 +2709,6 @@ class MemoryWriter(WritePipeline):
recent_history_count=_recent_history_count,
)
total_tokens += response.extra.get("_agent_tokens", 0)
if existing_raw_ids and response.extra.get("agent_stored_ids"):
await self._attach_existing_raw_ids(
vector_store,
list(response.extra["agent_stored_ids"]),
existing_raw_ids,
)
if response.extra.get("agent_status") == "failed":
await self._emit_pipeline_step(
request,
@@ -2771,13 +2718,12 @@ class MemoryWriter(WritePipeline):
"error": response.extra.get("agent_error", ""),
},
)
if not existing_raw_ids:
await self._rollback_failed_l1(
request=request,
response=response,
vector_store=vector_store,
memory_id=memory_id,
)
await self._rollback_failed_l1(
request=request,
response=response,
vector_store=vector_store,
memory_id=memory_id,
)
response.tokens_used = total_tokens
_failed_workflow_ms = (
datetime.now() - _t_workflow
+6 -19
View File
@@ -29,7 +29,7 @@ import logging
import os
import re
import signal
import threading
import sys
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, Optional
@@ -495,16 +495,6 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
_json_response(self, 200, result)
def _request_shutdown(server: ThreadingHTTPServer) -> None:
"""Ask ``serve_forever`` to stop without blocking the calling thread.
``BaseServer.shutdown()`` waits for the ``serve_forever`` loop to exit. A
signal handler runs on the very thread executing that loop, so calling it
there deadlocks forever. Hand the call to a helper thread instead.
"""
threading.Thread(target=server.shutdown, daemon=True).start()
def run_server(port: int = 19527, host: str = "127.0.0.1"):
"""Start the HTTP server (blocking)."""
# Configure logging — basicConfig for root; hy_memory level set after client init
@@ -533,12 +523,13 @@ def run_server(port: int = 19527, host: str = "127.0.0.1"):
logger.info(f"[server] Listening on http://{host}:{port}")
logger.info(f"[server] Health check: http://{host}:{port}/healthz")
# Graceful shutdown. The signal handler only asks a helper thread to stop
# the loop; cleanup runs after serve_forever() returns so it cannot race
# the request threads or deadlock the serving thread.
# Graceful shutdown
def _shutdown(signum, frame):
logger.info("[server] Shutting down...")
_request_shutdown(server)
server.shutdown()
if _client is not None:
_client.close()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
@@ -547,10 +538,6 @@ def run_server(port: int = 19527, host: str = "127.0.0.1"):
server.serve_forever()
except KeyboardInterrupt:
_shutdown(None, None)
finally:
server.server_close()
if _client is not None:
_client.close()
def main():
-150
View File
@@ -1,150 +0,0 @@
"""Public, offline snapshot support for the SDK-owned local data plane."""
from __future__ import annotations
import hashlib
import os
import shutil
from dataclasses import dataclass
from pathlib import Path
class DataPlaneSnapshotRejected(RuntimeError):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
@dataclass(frozen=True)
class DataPlaneSnapshot:
source_root: Path
snapshot_root: Path
files: tuple[dict[str, object], ...]
total_bytes: int
@dataclass(frozen=True)
class DataPlaneRestore:
restored_root: Path
previous_root: Path | None
files_restored: int
total_bytes: int
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def snapshot_local_data_plane(
*,
source_root: Path,
target_root: Path,
writers_stopped: bool,
) -> DataPlaneSnapshot:
"""Copy one quiesced local SDK root and return a verified inventory.
Remote providers require their own export implementation and must not call
this local-path contract.
"""
if not writers_stopped:
raise DataPlaneSnapshotRejected("data_plane_snapshot_writers_running")
source = source_root.resolve()
target = target_root.resolve()
if source.is_symlink() or not source.is_dir():
raise DataPlaneSnapshotRejected("data_plane_snapshot_source_invalid")
if target.exists():
raise DataPlaneSnapshotRejected("data_plane_snapshot_target_exists")
try:
target.mkdir(parents=True)
for root, directories, files in os.walk(source, followlinks=False):
root_path = Path(root)
relative = root_path.relative_to(source)
target_directory = target / relative
target_directory.mkdir(parents=True, exist_ok=True)
for name in directories:
if (root_path / name).is_symlink():
raise DataPlaneSnapshotRejected("data_plane_snapshot_symlink")
for name in files:
item = root_path / name
if item.is_symlink():
raise DataPlaneSnapshotRejected("data_plane_snapshot_symlink")
shutil.copy2(item, target_directory / name)
inventory: list[dict[str, object]] = []
for path in sorted(item for item in target.rglob("*") if item.is_file()):
inventory.append(
{
"path": path.relative_to(target).as_posix(),
"bytes": path.stat().st_size,
"sha256": _sha256(path),
}
)
for item in inventory:
copied = target / str(item["path"])
if copied.stat().st_size != item["bytes"] or _sha256(copied) != item["sha256"]:
raise DataPlaneSnapshotRejected("data_plane_snapshot_verify_failed")
except DataPlaneSnapshotRejected:
shutil.rmtree(target, ignore_errors=True)
raise
except OSError as error:
shutil.rmtree(target, ignore_errors=True)
raise DataPlaneSnapshotRejected("data_plane_snapshot_failed") from error
return DataPlaneSnapshot(
source_root=source,
snapshot_root=target,
files=tuple(inventory),
total_bytes=sum(int(item["bytes"]) for item in inventory),
)
def restore_local_data_plane(
*,
snapshot_root: Path,
target_root: Path,
writers_stopped: bool,
protective_root: Path | None = None,
) -> DataPlaneRestore:
"""Replace one local SDK root from a verified snapshot with rollback."""
if not writers_stopped:
raise DataPlaneSnapshotRejected("data_plane_restore_writers_running")
source = snapshot_root.resolve()
target = target_root.resolve()
protective = (
protective_root.resolve()
if protective_root is not None
else target.parent / f".{target.name}.pre-restore"
)
staging = target.parent / f".{target.name}.restore.tmp"
if target.is_symlink() or protective.exists() or staging.exists():
raise DataPlaneSnapshotRejected("data_plane_restore_target_unsafe")
copied = snapshot_local_data_plane(
source_root=source,
target_root=staging,
writers_stopped=True,
)
previous: Path | None = None
try:
if target.exists():
target.replace(protective)
previous = protective
staging.replace(target)
except OSError as error:
if previous is not None and previous.exists() and not target.exists():
previous.replace(target)
previous = None
shutil.rmtree(staging, ignore_errors=True)
raise DataPlaneSnapshotRejected("data_plane_restore_replace_failed") from error
return DataPlaneRestore(
restored_root=target,
previous_root=previous,
files_restored=len(copied.files),
total_bytes=copied.total_bytes,
)
+4 -4
View File
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
公共 Python SDK 为 `hy-memory==2.0.4`。MemHub 源码版本为 `1.0.4`
(build 13),GitHub 仓库为
公共 Python SDK 为 `hy-memory==2.0.3`。MemHub 源码版本为 `1.0.3`
(build 7),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
`v1.0.4` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
`v1.0.3` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
@@ -113,7 +113,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.4`。SDK 配置、权限边界及 search
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.3`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
@@ -76,7 +76,8 @@ def _memory(db: sqlite3.Connection, reference: CallReference) -> SourceRecordRef
if row["status"] == "deleted" or row["deleted_at"] is not None:
return _hidden(reference)
# Same local revocation/ownership predicate as SourceReadAccess.memories.
# Same local revocation/ownership predicate as SourceReadAccess.memories,
# evaluated BEFORE freshness (which can read support text), in this snapshot.
# Its public methods open their own connections, so cannot be used here.
access = db.execute(
"SELECT EXISTS (SELECT 1 FROM governed_memory_evidence "
@@ -120,8 +121,8 @@ def resolve_call_references(database_path: Path, event: CallEvent) -> list[Sourc
Local-owner only: event host attribution is NOT authorization. Explicit local
source revocations deny access; absent grants retain legacy local semantics.
A memory needs at least one accessible active citation. Evidence freshness
proofs are retired and do not affect availability.
A memory needs at least one accessible active citation. No proof preserves
shared freshness compatibility, not a claim of verified historical freshness.
Any read/parse/budget failure raises a fixed whole-request error, never a
partial result or per-item deletion. The route owner must map this separately
@@ -34,11 +34,10 @@ class ScanIssueCode(StrEnum):
SOURCE_BUSY = "source_busy"
UNSUPPORTED_SCHEMA = "unsupported_schema"
BUDGET_EXCEEDED = "budget_exceeded"
#: The file is bigger than a per-file ceiling: either the import's own
#: ceiling or the access gate's per-root `max_file_bytes`. Both are skipped
#: with this reason rather than read, because one oversized history file
#: can hold the import open long enough to take the process with it, and
#: the user-facing cause is the same size limit.
#: The file is bigger than the import's own per-file ceiling. Skipped with
#: a reason rather than read, because one oversized history file can hold
#: the import open long enough to take the process with it. Distinct from
#: the access layer's `FILE_TOO_LARGE`, which is a per-root limit.
FILE_TOO_LARGE = "file_too_large"
HISTORY_WINDOW_EXCLUDED = "history_window_excluded"
@@ -1120,11 +1120,6 @@ def discovery_failure_code(
) -> ScanIssueCode:
if failure.reason_code is SourceAccessErrorCode.BUDGET_EXCEEDED:
return ScanIssueCode.BUDGET_EXCEEDED
if failure.reason_code is SourceAccessErrorCode.FILE_TOO_LARGE:
# The file is readable and its format is fine; only its size is over
# the access gate. Reporting that as `unsupported_schema` sends the
# user looking for a parser problem that does not exist.
return ScanIssueCode.FILE_TOO_LARGE
return ScanIssueCode.UNSUPPORTED_SCHEMA
@@ -1132,8 +1127,6 @@ def _scan_failure_code(code: SourceAccessErrorCode) -> ScanIssueCode:
"""Map a per-file open failure onto a machine code, never onto free text."""
if code is SourceAccessErrorCode.BUDGET_EXCEEDED:
return ScanIssueCode.BUDGET_EXCEEDED
if code is SourceAccessErrorCode.FILE_TOO_LARGE:
return ScanIssueCode.FILE_TOO_LARGE
if code is SourceAccessErrorCode.SYMLINK_NOT_ALLOWED:
return ScanIssueCode.UNSUPPORTED_SCHEMA
if code in {
@@ -1,25 +1,36 @@
"""Memory evidence freshness checks are retired.
"""Read-only, exact-revision support checks inside the caller's SQLite snapshot.
Read paths used to re-validate ``memory_task_outputs.support_refs`` against
live ``evidence`` rows via ``is_memory_evidence_current``. That design hid
memories when evidence changed, but at high cost (library 503s, per-row SQL,
segment-key breakage). Call sites still import this module; registration is
now a no-op that always admits the row.
Proof rows may still be written by the ingest path; they are ignored on read.
See ``新鲜度_todo.md`` for the remaining teardown steps.
No proof is legacy/unknown, not stale. One complete fresh proof suffices; all
complete proofs stale means false. Malformed records raise. An exceeded budget
means the memory is too large to verify, not that it is stale: it reads as
current, the same as having no proof, and is reported once in a while as a
content-free diagnostic. One such memory used to fail every COUNT over the
library, so the homepage returned 503 for as long as it existed (D76).
Set ONE_MEMORY_FRESHNESS_BUDGET_RAISES=1 to restore the old raising behavior.
The caller owns BEGIN/deadline/progress-handler and error-to-unavailable mapping.
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import threading
import time
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from one_memory.database_access import register_function
from one_memory.diagnostic_log import diagnostic_event
from one_memory.imports.models import EvidenceRecord
from one_memory.memory_tasks import MemoryTaskOutput, matches_evidence_ref
#: Historical budget error codes. Retained so diagnostics/env docs do not break
#: mid-teardown; nothing raises them on the read path anymore.
_PROOFS = 64
_REFERENCES = 128
_BODY_BYTES = 65_536
_EVIDENCE_BYTES = 8 * 1024 * 1024
_SCOPE_STEPS = 64
#: Budget refusals: the check could not finish, the data is not malformed.
BUDGET_ERRORS = frozenset(
{
"memory_freshness_proof_budget",
@@ -28,23 +39,80 @@ BUDGET_ERRORS = frozenset(
"memory_freshness_scope_budget",
}
)
_REPORT_INTERVAL_SECONDS = 60.0
_reported: dict[str, tuple[float, int]] = {}
_reported_lock = threading.Lock()
def budget_errors_raise() -> bool:
"""Former opt-in to raise on budget overrun. Always false after retirement."""
return False
"""Opt back into the pre-D76 behavior, where one large memory fails the query."""
return os.environ.get("ONE_MEMORY_FRESHNESS_BUDGET_RAISES", "").strip() == "1"
def has_proof_table(db: sqlite3.Connection) -> bool:
"""Whether ``memory_task_outputs`` exists. Unused by the retired check."""
return (
db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_task_outputs'"
).fetchone()
is not None
def _report_budget(code: str) -> None:
"""Count every budget skip; write at most one line per reason per interval."""
now = time.monotonic()
with _reported_lock:
last, pending = _reported.get(code, (float("-inf"), 0))
pending += 1
if now - last < _REPORT_INTERVAL_SECONDS:
_reported[code] = (last, pending)
return
_reported[code] = (now, 0)
diagnostic_event(
level="warn",
component="memory.freshness",
event="memory_freshness.budget_exceeded",
outcome="skipped",
details={"reason": code, "count": pending},
)
def _object(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError("memory_freshness_invalid_proof")
result[key] = value
return result
class _Prefetched:
"""Prefetched rows for the current batch, private to thread and connection.
Scoped, never global: a cache that outlived the transaction would answer
from rows a later write had already replaced.
"""
def __init__(self) -> None:
self._local = threading.local()
def get(self) -> dict[str, list[sqlite3.Row]] | None:
return getattr(self._local, "rows", None)
def evidence(self) -> dict[str, sqlite3.Row] | None:
return getattr(self._local, "evidence", None)
@contextmanager
def using(
self,
rows: dict[str, list[sqlite3.Row]],
evidence: dict[str, sqlite3.Row] | None = None,
) -> Iterator[None]:
previous = getattr(self._local, "rows", None)
previous_evidence = getattr(self._local, "evidence", None)
self._local.rows = rows
self._local.evidence = evidence
try:
yield
finally:
self._local.rows = previous
self._local.evidence = previous_evidence
_prefetched_proofs = _Prefetched()
@contextmanager
def freshness_proofs_prefetched(
db: sqlite3.Connection,
@@ -52,16 +120,328 @@ def freshness_proofs_prefetched(
*,
evidence_ids: Sequence[str] = (),
) -> Iterator[None]:
"""No-op. Kept so batched read callers need not change in this PR."""
del db, memory_ids, evidence_ids
yield
"""Serve the freshness check's proof reads from one query.
The decisions are unchanged — the rows come from the same transaction —
but the per-memory reads collapse into the one in `proof_rows_for`.
"""
with _prefetched_proofs.using(
proof_rows_for(db, memory_ids),
evidence_rows_for(db, evidence_ids) if evidence_ids else None,
):
yield
def has_proof_table(db: sqlite3.Connection) -> bool:
"""Whether this database keeps proofs at all.
Without the table the registered UDF answers 1 unconditionally, so there
is nothing to prefetch and nothing to read.
"""
return (
db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_task_outputs'"
).fetchone()
is not None
)
_EVIDENCE_COLUMNS = (
"evidence_id,user_id,source_id,workspace,project,conversation_id,"
"message_id,correlation_id,source_ordinal,content_kind,role,written_by,"
"created_at,verbatim,content_hash,revision,imported_at,updated_at"
)
#: The budget measure the single read applies, kept as one string so the
#: batched read cannot compute it differently.
_EVIDENCE_SIZE_SQL = (
"length(CAST(verbatim AS BLOB)) + "
"length(CAST(json_array(evidence_id,user_id,source_id,workspace,project,"
"conversation_id,message_id,correlation_id,source_ordinal,content_kind,"
"role,written_by,created_at,content_hash,revision,imported_at,updated_at) "
"AS BLOB))"
)
def evidence_rows_for(
db: sqlite3.Connection,
evidence_ids: Sequence[str],
) -> dict[str, sqlite3.Row]:
"""Read the cited evidence rows for a page in one query.
The check reads every citation twice — once for its size, against the
evidence budget, and once for the row it validates against the proof's
reference. Both are per citation, so both grow with the page.
The size comes from the same expression the single read uses, so the
budget arithmetic cannot drift between the two paths.
"""
if not evidence_ids:
return {}
cursor = db.cursor()
cursor.row_factory = sqlite3.Row
by_id: dict[str, sqlite3.Row] = {}
for start in range(0, len(evidence_ids), 900):
batch = list(dict.fromkeys(evidence_ids[start : start + 900]))
placeholders = ",".join("?" for _ in batch)
for row in cursor.execute(
f"SELECT {_EVIDENCE_COLUMNS},{_EVIDENCE_SIZE_SQL} AS measure "
f"FROM evidence WHERE evidence_id IN ({placeholders})",
batch,
):
by_id[str(row["evidence_id"])] = row
return by_id
def proof_rows_for(
db: sqlite3.Connection,
memory_ids: Sequence[str],
) -> dict[str, list[sqlite3.Row]]:
"""Read the proof rows for a whole page in one query.
The freshness check reads these per memory, and that is the read path's
whole cost: measured on the old-user library, 160 memories cost 0.69 s in
the UDF against 0.08 s for the citation subquery, and grew further while
the library was also being written to — enough to cross the five second
interrupt on `/api/v1/memories` and on the map.
Returns only ids that have rows; a caller falling back to a single read
for anything absent keeps the same answer.
"""
if not memory_ids:
return {}
if not has_proof_table(db):
return {}
cursor = db.cursor()
cursor.row_factory = sqlite3.Row
by_id: dict[str, list[sqlite3.Row]] = {}
for start in range(0, len(memory_ids), 400):
batch = list(memory_ids[start : start + 400])
placeholders = ",".join("?" for _ in batch)
for row in cursor.execute(
"SELECT task_id,memory_id,length(CAST(body AS BLOB)) AS size,"
"substr(CAST(body AS BLOB),1,?) AS body FROM memory_task_outputs "
f"WHERE memory_id IN ({placeholders})",
(_BODY_BYTES + 1, *batch),
):
by_id.setdefault(str(row["memory_id"]), []).append(row)
return by_id
def _proof_revision(
cursor: sqlite3.Cursor,
memory_id: str,
revision: int,
user_id: str,
available: set[int],
) -> int:
if revision in available or not available:
return revision
if (
cursor.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='governed_memory_versions'"
).fetchone()
is None
):
return revision
original = revision
for step in range(_SCOPE_STEPS + 1):
if revision in available:
return revision
row = cursor.execute(
"SELECT v.action, typeof(v.content)='text' AND typeof(p.content)='text' "
"AND CAST(v.content AS BLOB)=CAST(p.content AS BLOB) AS same_content, "
"v.status=p.status AS same_status, "
"CAST(v.content AS BLOB)=CAST(m.content AS BLOB) AS current_content "
"FROM governed_memory_versions v "
"JOIN governed_memory_versions p ON p.memory_id=v.memory_id "
"AND p.revision=v.revision-1 "
"JOIN governed_memories m ON m.memory_id=v.memory_id "
"WHERE v.memory_id=? AND v.revision=? AND m.user_id=? AND m.revision=?",
(memory_id, revision, user_id, original),
).fetchone()
if row is None or row[0] != "set_scopes" or not all(row[index] == 1 for index in (1, 2, 3)):
return original
if step == _SCOPE_STEPS:
raise ValueError("memory_freshness_scope_budget")
revision -= 1
raise ValueError("memory_freshness_scope_budget")
def register_memory_freshness(db: sqlite3.Connection) -> str:
"""Register a always-true UDF; return SQL predicate ``1``.
"""Register UDF; return a predicate for alias m. Does not change row_factory.
Callers that embed the return value in ``WHERE`` keep working. Callers that
invoke ``is_memory_evidence_current`` by name also get current.
Production initialization supplies memory_task_outputs(memory_id) index.
Old no-index databases depend on the caller's SQL deadline. Per evaluation:
65 rows / 64KiB each, 129 active IDs, 8MiB total Evidence material. No library
bodies are loaded; Evidence objects live only for this evaluation. No cache
persists across transactions (safe when a caller reuses its connection).
"""
register_function(db, "is_memory_evidence_current", 3, lambda *_: 1)
return "1"
if not has_proof_table(db):
register_function(db, "is_memory_evidence_current", 3, lambda *_: 1)
return "1"
raising = budget_errors_raise()
def current(memory_id: str, revision: int, user_id: str) -> int:
try:
return checked(memory_id, revision, user_id)
except ValueError as error:
code = str(error)
if raising or code not in BUDGET_ERRORS:
raise
_report_budget(code)
return 1
except sqlite3.Error as error:
# A freshness proof is an enhancement, never the reason a person
# cannot see their library. Under a concurrent history import the
# 5s read deadline interrupts this UDF mid-statement; the old
# behaviour let that escape, and the caller turned it into
# `memory_library_unavailable`, so the memory list stayed empty for
# as long as the import ran. Answer "current" instead and record
# why, which is the same trade the D76 budget skip already makes.
_report_budget("memory_freshness_sql_error")
logging.getLogger(__name__).warning(
"memory_freshness_udf_failed %s: %s", type(error).__name__, error
)
return 1
def checked(memory_id: str, revision: int, user_id: str) -> int:
if not isinstance(memory_id, str) or type(revision) is not int or revision < 1:
raise ValueError("memory_freshness_invalid_identity")
if not isinstance(user_id, str):
raise ValueError("memory_freshness_invalid_identity")
cursor = db.cursor()
cursor.row_factory = sqlite3.Row
prefetched = _prefetched_proofs.get()
if prefetched is not None:
rows = prefetched.get(memory_id, ())
else:
rows = cursor.execute(
"SELECT task_id,memory_id,length(CAST(body AS BLOB)) AS size,"
"substr(CAST(body AS BLOB),1,?) AS body FROM memory_task_outputs "
"WHERE memory_id=? LIMIT ?",
(_BODY_BYTES + 1, memory_id, _PROOFS + 1),
).fetchall()
rows = list(rows)
if len(rows) > _PROOFS:
raise ValueError("memory_freshness_proof_budget")
decoded = []
for row in rows:
if type(row["size"]) is not int or row["size"] > _BODY_BYTES:
raise ValueError("memory_freshness_proof_budget")
body = json.loads(row["body"], object_pairs_hook=_object)
if not isinstance(body, dict) or type(body.get("memory_revision")) is not int:
raise ValueError("memory_freshness_invalid_proof")
decoded.append((row, body))
revision = _proof_revision(
cursor,
memory_id,
revision,
user_id,
{body["memory_revision"] for _, body in decoded},
)
applicable = []
for row, body in decoded:
if body["memory_revision"] != revision:
continue
raw_refs = body.get("support_refs", [])
if not isinstance(raw_refs, list) or any(
not isinstance(ref, dict)
or set(ref) - {"evidence_id", "revision", "content_hash", "identity_hash"}
for ref in raw_refs
):
raise ValueError("memory_freshness_invalid_reference")
output = MemoryTaskOutput.model_validate_json(row["body"], strict=True)
if output.memory_id != memory_id or output.task_id != row["task_id"]:
raise ValueError("memory_freshness_proof_identity")
if len(output.support_refs) > _REFERENCES:
raise ValueError("memory_freshness_reference_budget")
for ref in output.support_refs:
if (
set(ref) - {"evidence_id", "revision", "content_hash", "identity_hash"}
or not ref["evidence_id"]
or ref["revision"] < 1
or any(
len(ref[key]) != 64
or any(char not in "0123456789abcdef" for char in ref[key])
for key in ("content_hash", "identity_hash")
if key in ref
)
):
raise ValueError("memory_freshness_invalid_reference")
proof_ids = [ref["evidence_id"] for ref in output.support_refs]
if len(proof_ids) != len(set(proof_ids)):
raise ValueError("memory_freshness_duplicate_reference")
applicable.append(output)
if not applicable:
return 1
active = cursor.execute(
"SELECT evidence_id FROM governed_memory_evidence "
"WHERE memory_id=? AND active=1 LIMIT ?",
(memory_id, _REFERENCES + 1),
).fetchall()
if len(active) > _REFERENCES:
raise ValueError("memory_freshness_reference_budget")
ids = {row[0] for row in active}
complete = [
output
for output in applicable
if output.support_refs and {ref["evidence_id"] for ref in output.support_refs} == ids
]
if not complete:
return 1
records: dict[str, EvidenceRecord | None] = {}
used = 0
prefetched_evidence = _prefetched_proofs.evidence()
for identifier in ids:
evidence_row = (
prefetched_evidence.get(identifier) if prefetched_evidence is not None else None
)
if evidence_row is not None:
size = evidence_row["measure"]
if type(size) is not int or size < 0:
raise ValueError("memory_freshness_invalid_evidence")
else:
measured = cursor.execute(
f"SELECT {_EVIDENCE_SIZE_SQL} FROM evidence WHERE evidence_id=?",
(identifier,),
).fetchone()
if measured is None:
records[identifier] = None
continue
if type(measured[0]) is not int or measured[0] < 0:
raise ValueError("memory_freshness_invalid_evidence")
size = measured[0]
used += size
if used > _EVIDENCE_BYTES:
raise ValueError("memory_freshness_evidence_budget")
if evidence_row is None:
evidence_row = cursor.execute(
f"SELECT {_EVIDENCE_COLUMNS} FROM evidence WHERE evidence_id=?",
(identifier,),
).fetchone()
if evidence_row is None:
records[identifier] = None
continue
# evidence_ref explicitly excludes origin_meta/imported_at/updated_at.
# `sqlite3.Row` iterates values, not column names, so pair the
# names with the values explicitly.
fields = {
name: value
for name, value in zip(evidence_row.keys(), tuple(evidence_row), strict=False)
if name != "measure"
}
records[identifier] = EvidenceRecord.model_validate({**fields, "origin_meta": {}})
for output in complete:
if all(
(record := records[ref["evidence_id"]]) is not None
and record.user_id == user_id
and matches_evidence_ref(record, ref)
for ref in output.support_refs
):
return 1
return 0
register_function(db, "is_memory_evidence_current", 3, current)
return "is_memory_evidence_current(m.memory_id,m.revision,m.user_id)"
+1 -1
View File
@@ -203,7 +203,7 @@
| `test_memory_correction_sync.py` | `integration` | R-27: a correction that only lands locally, while HY keeps the old text. |
| `test_memory_deletion_outbox.py` | `contract` | 删除发件箱:一条删除同一时刻只归一个执行者,结果只由持有领取凭证的那个写。 |
| `test_memory_dispatch_lifecycle.py` | `contract` | Exercise the automatic enqueue boundary without producing substitute memories. |
| `test_memory_evidence_freshness.py` | `contract` | 新鲜度拆除后:读路径恒为 current;证据修订 / 畸形 proof / segment ref 不再藏记忆或 503。 |
| `test_memory_evidence_freshness.py` | `contract` | 覆盖 fresh then revised and identity only change、any complete fresh proof wins over old proof、other revision and incomplete proof do not hide legacy or correction 等 21 个场景。 |
| `test_memory_freshness_integration_review.py` | `integration` | Independent synthetic read-path review; no lifespan, model or user database. |
| `test_memory_governance.py` | `contract` | 覆盖 correction keeps verbatim and appends history、mutations are revision checked and request idempotent、delete result survives restart and request replay 等 42 个场景。 |
| `test_memory_governance_api.py` | `integration` | 覆盖 memory governance api preserves evidence and reports cleanup、memory validation does not echo content 场景。 |
@@ -355,9 +355,8 @@ def test_actual_evidence_proof_revision_and_corrected_memory(fresh):
assert all(item.available for item in resolve_call_references(path, event(raw, reference)))
evidence.put(value.model_copy(update={"verbatim": "revised evidence"}))
result = resolve_call_references(path, event(raw, reference))
# Raw refs still track evidence.revision; memory refs no longer use freshness.
assert_hidden(result[0], raw, "changed")
assert result[1].available
assert_hidden(result[1], reference, "changed")
corrected = as_person(MemoryGovernanceStore(path)).correct(
memory.memory_id,
user_id="local",
@@ -370,7 +369,7 @@ def test_actual_evidence_proof_revision_and_corrected_memory(fresh):
assert resolve_call_references(path, event(current))[0].title == corrected.content
def test_scope_only_revision_keeps_memory_readable_when_evidence_changes(fresh):
def test_scope_only_revision_still_checks_original_support(fresh):
path, evidence, value, record, memory, tasks, output = fresh
updated = as_person(MemoryGovernanceStore(path)).set_scopes(
memory.memory_id,
@@ -382,27 +381,25 @@ def test_scope_only_revision_keeps_memory_readable_when_evidence_changes(fresh):
reference = memory_ref(updated.memory_id, updated.revision)
assert resolve_call_references(path, event(reference))[0].available
evidence.put(value.model_copy(update={"verbatim": "revised evidence"}))
assert resolve_call_references(path, event(reference))[0].available
assert_hidden(resolve_call_references(path, event(reference))[0], reference, "changed")
def test_evidence_identity_change_without_revision_keeps_memory_readable(fresh):
def test_freshness_identity_change_without_revision_change(fresh):
path, evidence, value, record, memory, tasks, output = fresh
reference = memory_ref(memory.memory_id, memory.revision)
assert resolve_call_references(path, event(reference))[0].available
with closing(sqlite3.connect(path)) as db, db:
db.execute("UPDATE evidence SET workspace='workspace:changed'")
assert resolve_call_references(path, event(reference))[0].available
assert_hidden(resolve_call_references(path, event(reference))[0], reference, "changed")
def test_invalid_proof_is_ignored_but_revocation_still_hides_memory(fresh):
def test_invalid_freshness_proof_fails_whole_response_but_revoked_never_reads_it(fresh):
path, evidence, value, record, memory, tasks, output = fresh
reference = memory_ref(memory.memory_id, memory.revision)
with closing(sqlite3.connect(path)) as db, db:
db.execute("UPDATE memory_task_outputs SET body='invalid secret proof'")
assert all(
item.available
for item in resolve_call_references(path, event(ref(key=record.evidence_id), reference))
)
with pytest.raises(CallReferencesUnavailable, match="^call_references_unavailable$"):
resolve_call_references(path, event(ref(key=record.evidence_id), reference))
with closing(sqlite3.connect(path)) as db, db:
grant(db, "cursor")
result = resolve_call_references(path, event(reference))
@@ -458,13 +455,9 @@ def test_authorization_revision_freshness_and_titles_share_one_snapshot(fresh, m
assert [item.title for item in snapshot] == [memory.content, record.verbatim]
assert changed == [True] and reads and all(reads)
current = resolve_call_references(path, event(reference, raw))
# Memory refs no longer hide on evidence revision/identity via freshness.
if change == "evidence":
assert current[0].available
else:
assert_hidden(
current[0], reference, "changed" if change == "memory" else "unavailable"
)
assert_hidden(
current[0], reference, "changed" if change in ("evidence", "memory") else "unavailable"
)
assert current[1].available == (change in ("memory", "link"))
@@ -8,11 +8,6 @@ import pytest
pytestmark = [pytest.mark.performance, pytest.mark.serial]
SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "bench_context_raw_scale.py"
if not SCRIPT.exists():
pytest.skip(
f"benchmark script is not part of this checkout: {SCRIPT.name}",
allow_module_level=True,
)
spec = importlib.util.spec_from_file_location("bench_context_raw_scale", SCRIPT)
benchmark = importlib.util.module_from_spec(spec)
sys.modules["bench_context_raw_scale"] = benchmark
@@ -1,5 +1,3 @@
"""Freshness retirement: read path always admits, even when evidence changed."""
import json
import sqlite3
@@ -7,11 +5,7 @@ import pytest
from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
from one_memory.imports.storage import EvidenceStore
from one_memory.memory_evidence_freshness import (
budget_errors_raise,
freshness_proofs_prefetched,
register_memory_freshness,
)
from one_memory.memory_evidence_freshness import register_memory_freshness
from one_memory.memory_governance import MemoryDraft, MemoryGovernanceStore, MemoryKind
from one_memory.memory_tasks import MemoryTaskOutput, MemoryTaskStore, evidence_ref
from tests.person_door import as_person
@@ -67,45 +61,46 @@ def check(path, memory):
).fetchone()[0]
def test_register_returns_constant_true_predicate(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute("BEGIN")
assert register_memory_freshness(db) == "1"
assert db.execute("SELECT is_memory_evidence_current(?,?,?)", ("x", 1, "local")).fetchone() == (
1,
)
def test_revised_evidence_no_longer_hides_memory(case):
def test_fresh_then_revised_and_identity_only_change(case):
path, evidence, value, record, memory, tasks, output = case
assert check(path, memory) == 1
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
assert check(path, memory) == 0
def test_any_complete_fresh_proof_wins_over_old_proof(case):
path, evidence, value, record, memory, tasks, output = case
revised = evidence.put(value.model_copy(update={"verbatim": "new statement"})).record
tasks.save_output(
output.model_copy(update={"task_id": "task2", "support_refs": (evidence_ref(revised),)})
)
assert check(path, memory) == 1
def test_segment_support_refs_do_not_fail_reads(case):
def test_other_revision_and_incomplete_proof_do_not_hide_legacy_or_correction(case):
path, evidence, value, record, memory, tasks, output = case
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
tasks.save_output(output.model_copy(update={"memory_revision": memory.revision + 1}))
assert check(path, memory) == 1
tasks.save_output(output.model_copy(update={"support_refs": ()}))
assert check(path, memory) == 1
@pytest.mark.parametrize("broken", ["json", "identity", "ref"])
def test_bad_proof_raises_not_false_empty(case, broken):
path, evidence, value, record, memory, tasks, output = case
body = output.model_dump(mode="json")
body["support_refs"][0].update(
segment_index=0,
segment_start=0,
segment_end=len(record.verbatim),
segment_hash="0" * 64,
)
if broken == "identity":
body["memory_id"] = "other"
if broken == "ref":
body["support_refs"][0]["revision"] = "1"
payload = json.dumps(body)
if broken == "json":
payload = "{"
with sqlite3.connect(path) as db:
db.execute("UPDATE memory_task_outputs SET body=?", (json.dumps(body),))
assert check(path, memory) == 1
def test_malformed_proof_does_not_fail_reads(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute(
"UPDATE memory_task_outputs SET body=? WHERE memory_id=?",
("{", memory.memory_id),
)
assert check(path, memory) == 1
db.execute("UPDATE memory_task_outputs SET body=?", (payload,))
with pytest.raises(sqlite3.OperationalError):
check(path, memory)
def test_missing_table_compatibility_is_readonly():
@@ -116,19 +111,239 @@ def test_missing_table_compatibility_is_readonly():
assert db.execute("SELECT name FROM sqlite_master").fetchall() == []
def test_prefetch_is_noop(case):
def test_table_presence_is_checked_once_at_registration(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute("BEGIN")
with freshness_proofs_prefetched(db, [memory.memory_id], evidence_ids=[record.evidence_id]):
assert register_memory_freshness(db) == "1"
assert check(path, memory) == 1
statements = []
db.set_trace_callback(statements.append)
predicate = register_memory_freshness(db)
for _ in range(3):
assert db.execute(f"SELECT {predicate} FROM governed_memories m").fetchone() == (1,)
assert sum("sqlite_master" in statement for statement in statements) == 1
def test_budget_raise_env_is_inert(case, monkeypatch):
def test_proof_budget_reads_current_instead_of_failing(case):
path, evidence, value, record, memory, tasks, output = case
monkeypatch.setenv("ONE_MEMORY_FRESHNESS_BUDGET_RAISES", "1")
assert budget_errors_raise() is False
for index in range(65):
tasks.save_output(output.model_copy(update={"task_id": f"task-{index}"}))
assert check(path, memory) == 1
def test_proof_budget_still_raises_when_opted_back(case, monkeypatch):
path, evidence, value, record, memory, tasks, output = case
monkeypatch.setenv("ONE_MEMORY_FRESHNESS_BUDGET_RAISES", "1")
for index in range(65):
tasks.save_output(output.model_copy(update={"task_id": f"task-{index}"}))
with pytest.raises(sqlite3.OperationalError):
check(path, memory)
def test_snapshot_cache_does_not_hide_changes_across_transactions(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute("BEGIN")
predicate = register_memory_freshness(db)
query = f"SELECT {predicate} FROM governed_memories m"
assert db.execute(query).fetchone() == (1,)
db.commit()
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
db.execute("BEGIN")
register_memory_freshness(db)
assert db.execute(query).fetchone() == (0,)
def test_identity_hash_detects_metadata_change_without_revision_or_content_change(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute("UPDATE evidence SET workspace='workspace:changed'")
assert check(path, memory) == 0
def test_incomplete_fresh_proof_cannot_override_complete_stale(case):
path, evidence, value, record, memory, tasks, output = case
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
tasks.save_output(output.model_copy(update={"task_id": "partial", "support_refs": ()}))
assert check(path, memory) == 0
def test_sql_query_only_and_missing_proof_compatibility(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute("DELETE FROM memory_task_outputs")
with sqlite3.connect(path) as db:
db.execute("PRAGMA query_only=ON")
db.execute("BEGIN")
before = db.total_changes
predicate = register_memory_freshness(db)
assert db.execute(f"SELECT {predicate} FROM governed_memories m").fetchone() == (1,)
assert db.total_changes == before
def test_oversized_evidence_reads_current_without_loading_body(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute("UPDATE evidence SET verbatim=zeroblob(?)", (8 * 1024 * 1024 + 1,))
assert check(path, memory) == 1
@pytest.mark.parametrize("revise_first", [False, True])
def test_scope_only_chain_preserves_old_support_guard(case, revise_first):
path, evidence, value, record, memory, tasks, output = case
governance = MemoryGovernanceStore(path)
if revise_first:
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
for index in range(3):
memory = as_person(governance).set_scopes(
memory.memory_id,
user_id="local",
request_id=f"scope-{index}",
expected_revision=memory.revision,
scopes=(f"workspace:{index}",),
)
if not revise_first:
assert check(path, memory) == 1
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
assert check(path, memory) == 0
@pytest.mark.parametrize("action", ["correct", "undo", "hy_reconcile", "unknown"])
def test_scope_chain_does_not_cross_non_scope_action(case, action):
path, evidence, value, record, memory, tasks, output = case
governance = MemoryGovernanceStore(path)
memory = as_person(governance).correct(
memory.memory_id,
user_id="local",
request_id="correct",
expected_revision=memory.revision,
content=memory.content,
)
with sqlite3.connect(path) as db:
db.execute(
"UPDATE governed_memory_versions SET action=? WHERE revision=?",
(action, memory.revision),
)
memory = as_person(governance).set_scopes(
memory.memory_id,
user_id="local",
request_id="scope",
expected_revision=memory.revision,
scopes=("workspace:changed",),
)
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
assert check(path, memory) == 1
def test_scope_chain_budget_reads_current(case):
path, evidence, value, record, memory, tasks, output = case
governance = MemoryGovernanceStore(path)
for index in range(65):
memory = as_person(governance).set_scopes(
memory.memory_id,
user_id="local",
request_id=f"scope-{index}",
expected_revision=memory.revision,
scopes=(f"workspace:{index}",),
)
assert check(path, memory) == 1
def test_changed_content_in_scope_history_is_not_inherited(case):
path, evidence, value, record, memory, tasks, output = case
governance = MemoryGovernanceStore(path)
memory = as_person(governance).set_scopes(
memory.memory_id,
user_id="local",
request_id="scope",
expected_revision=memory.revision,
scopes=("workspace:changed",),
)
with sqlite3.connect(path) as db:
db.execute(
"UPDATE governed_memory_versions SET content='different' WHERE revision=?",
(output.memory_revision,),
)
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
assert check(path, memory) == 1
def test_scope_chain_keeps_large_governed_content_inside_sql(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
content = "x" * 1_000_000
db.execute("UPDATE governed_memories SET content=?", (content,))
db.execute("UPDATE governed_memory_versions SET content=?", (content,))
governance = MemoryGovernanceStore(path)
memory = as_person(governance).set_scopes(
memory.memory_id,
user_id="local",
request_id="scope",
expected_revision=memory.revision,
scopes=("workspace:changed",),
)
evidence.put(value.model_copy(update={"verbatim": "new statement"}))
class BoundedCursor(sqlite3.Cursor):
def fetchone(self):
row = super().fetchone()
if row is not None:
assert all(not isinstance(item, str) or len(item) < 65_536 for item in row)
return row
class BoundedConnection(sqlite3.Connection):
def cursor(self, factory=BoundedCursor):
return super().cursor(factory)
with sqlite3.connect(path, factory=BoundedConnection) as db:
db.execute("BEGIN")
predicate = register_memory_freshness(db)
assert db.execute(f"SELECT {predicate} FROM governed_memories m").fetchone() == (0,)
def _confirmed_with_oversized_proof(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute(
"UPDATE memory_task_outputs SET body=? WHERE memory_id=?",
(
json.dumps({"memory_revision": memory.revision, "pad": "x" * 70_000}),
memory.memory_id,
),
)
return path, memory
def test_one_oversized_memory_no_longer_fails_the_library_count(case):
"""D76: one memory whose proof is too large used to 503 the homepage forever."""
path, memory = _confirmed_with_oversized_proof(case)
with sqlite3.connect(path) as db:
db.execute("BEGIN")
predicate = register_memory_freshness(db)
count = db.execute(
f"SELECT COUNT(*) FROM governed_memories m WHERE {predicate}"
).fetchone()[0]
assert count == 1
def test_malformed_proof_still_raises(case):
path, evidence, value, record, memory, tasks, output = case
with sqlite3.connect(path) as db:
db.execute(
"UPDATE memory_task_outputs SET body=? WHERE memory_id=?", ("{}", memory.memory_id)
)
with pytest.raises(sqlite3.OperationalError):
check(path, memory)
def test_budget_skip_is_reported_without_content(case, monkeypatch):
import one_memory.memory_evidence_freshness as freshness
events = []
monkeypatch.setattr(freshness, "diagnostic_event", lambda **values: events.append(values))
monkeypatch.setattr(freshness, "_reported", {})
path, memory = _confirmed_with_oversized_proof(case)
for _ in range(3):
assert check(path, memory) == 1
assert len(events) == 1
assert events[0]["details"] == {"reason": "memory_freshness_proof_budget", "count": 1}
assert "pad" not in json.dumps(events[0])
@@ -102,11 +102,17 @@ def revise(case):
).record
def test_revised_evidence_remains_readable_after_freshness_retirement(case):
"""Evidence revision no longer hides library / context / source reads."""
def test_complete_stale_proof_hides_all_four_reads_without_mutation(case):
# D22: the app-facing totals (library total, homepage count) count with
# the cheap permission filters only, so a stale proof no longer removes a
# row from the totals; the freshness gate still hides it from every read
# that returns rows (context, source read access).
assert visible(case) == (1, 1, 1, 1)
revise(case)
assert visible(case) == (1, 1, 1, 1)
total, home, context_items, source_items = visible(case)
# D22: home total counts cheaply and may keep the stale row; the reads
# that return rows still hide it.
assert (home, context_items, source_items) == (1, 0, 0)
assert case.governance.get(case.memory.memory_id, user_id="local") == case.memory
assert case.tasks.outputs(case.output.task_id) == (case.output,)
@@ -114,10 +120,11 @@ def test_revised_evidence_remains_readable_after_freshness_retirement(case):
@pytest.mark.parametrize(
"revise_first", [False, True], ids=["scope-before-revision", "scope-after-revision"]
)
def test_scope_only_revision_keeps_memory_readable_after_evidence_change(case, revise_first):
def test_scope_only_revision_must_not_resurrect_stale_derived_content(case, revise_first):
if revise_first:
revise(case)
assert visible(case) == (1, 1, 1, 1)
_, home, context_items, source_items = visible(case)
assert (context_items, source_items) == (0, 0)
changed = as_person(case.governance).set_scopes(
case.memory.memory_id,
user_id="local",
@@ -129,7 +136,8 @@ def test_scope_only_revision_keeps_memory_readable_after_evidence_change(case, r
if not revise_first:
assert visible(case, changed) == (1, 1, 1, 1)
revise(case)
assert visible(case, changed) == (1, 1, 1, 1)
_, home, context_items, source_items = visible(case, changed)
assert (context_items, source_items) == (0, 0)
def test_explicit_correct_remains_readable_but_never_overrides_revocation(case):
@@ -144,10 +152,7 @@ def test_explicit_correct_remains_readable_but_never_overrides_revocation(case):
assert visible(case, corrected) == (1, 1, 1, 1)
with closing(sqlite3.connect(case.path)) as db, db:
db.execute(
"""INSERT INTO source_grants
(user_id,source_id,root_path,requested_path,root_identity,
candidate_kind,granted_at,revoked_at,revision)
VALUES ('local','cursor','/fixture','/fixture','fixture','default',?,?,1)""",
"INSERT INTO source_grants VALUES ('local','cursor','/fixture','default',?,?)",
("2026-09-20T00:00:00Z", "2026-09-20T00:00:01Z"),
)
assert visible(case, corrected) == (0, 0, 0, 0)
@@ -176,10 +181,7 @@ def test_revocation_before_freshness_snapshot_cannot_release_memory(case, monkey
# Revocation commits before any SELECT pins the freshness transaction.
with closing(sqlite3.connect(case.path)) as writer, writer:
writer.execute(
"""INSERT INTO source_grants
(user_id,source_id,root_path,requested_path,root_identity,
candidate_kind,granted_at,revoked_at,revision)
VALUES ('local','cursor','/fixture','/fixture','fixture','default',?,?,1)""",
"INSERT INTO source_grants VALUES ('local','cursor','/fixture','default',?,?)",
("2026-09-20T00:00:00Z", "2026-09-20T00:00:01Z"),
)
changes.append(True)
@@ -192,7 +194,7 @@ def test_revocation_before_freshness_snapshot_cannot_release_memory(case, monkey
assert returned == []
def test_udf_stays_current_across_evidence_revision(case):
def test_udf_uses_caller_snapshot_then_observes_revision_in_next_transaction(case):
with closing(sqlite3.connect(case.path)) as reader:
reader.execute("PRAGMA journal_mode=WAL")
reader.execute("BEGIN")
@@ -204,7 +206,7 @@ def test_udf_stays_current_across_evidence_revision(case):
reader.commit()
reader.execute("BEGIN")
register_memory_freshness(reader)
assert reader.execute(sql).fetchone() == (1,)
assert reader.execute(sql).fetchone() == (0,)
def _oversize(case, bad):
@@ -225,16 +227,20 @@ def test_material_too_large_to_verify_reads_as_current(case, bad):
@pytest.mark.parametrize("bad", ["duplicate-key"])
def test_bad_proof_material_no_longer_makes_reads_unavailable(case, bad):
def test_bad_material_is_unavailable_not_successful_empty(case, bad):
with closing(sqlite3.connect(case.path)) as db, db:
db.execute(
"UPDATE memory_task_outputs SET body=?",
('{"memory_revision":2,"memory_revision":2}',),
)
assert visible(case) == (1, 1, 1, 1)
result = context(case)
assert result.status != "unavailable"
assert len(result.items) == 1
assert result.status == "unavailable" and result.reason == "read_unavailable"
assert result.items == ()
for read in (read_memory_library, read_home_summary):
with pytest.raises(sqlite3.Error):
read(case.path)
with pytest.raises(sqlite3.Error):
SourceReadAccess(case.path).memories([case.memory])
def test_route_serves_memories_whose_proof_is_too_large(case, tmp_path):
@@ -73,9 +73,8 @@ def put(
def grant(db, source, *, user="local", revoked=NOW):
db.execute(
"""INSERT OR REPLACE INTO source_grants
(user_id,source_id,root_path,requested_path,root_identity,
candidate_kind,granted_at,revoked_at,revision)
VALUES (?,?, '/synthetic', '/synthetic', 'synthetic', 'default', ?, ?, 1)""",
(user_id,source_id,root_path,candidate_kind,granted_at,revoked_at)
VALUES (?,?, '/synthetic', 'default', ?, ?)""",
(user, source, NOW, revoked),
)
@@ -8,11 +8,8 @@ from uuid import UUID
import pytest
from pydantic import ValidationError
requests_module = pytest.importorskip(
"one_memory.processing_requests",
reason="processing-request store is not part of the public Core snapshot",
)
from one_memory.processing_requests import ( # noqa: E402 - importorskip must run first
import one_memory.processing_requests as requests_module
from one_memory.processing_requests import (
ProcessingRequest,
ProcessingRequestConflictError,
ProcessingRequestState,
@@ -7,11 +7,6 @@ import pytest
pytestmark = [pytest.mark.performance, pytest.mark.serial]
SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "bench_raw_vector_scale.py"
if not SCRIPT.exists():
pytest.skip(
f"benchmark script is not part of this checkout: {SCRIPT.name}",
allow_module_level=True,
)
spec = importlib.util.spec_from_file_location("bench_raw_vector_scale", SCRIPT)
benchmark = importlib.util.module_from_spec(spec)
sys.modules["bench_raw_vector_scale"] = benchmark
@@ -8,11 +8,6 @@ import pytest
pytestmark = [pytest.mark.performance, pytest.mark.serial]
SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "bench_retrieval_eligibility_scale.py"
if not SCRIPT.exists():
pytest.skip(
f"benchmark script is not part of this checkout: {SCRIPT.name}",
allow_module_level=True,
)
spec = importlib.util.spec_from_file_location("bench_retrieval_eligibility_scale", SCRIPT)
benchmark = importlib.util.module_from_spec(spec)
sys.modules["bench_retrieval_eligibility_scale"] = benchmark
@@ -224,11 +224,6 @@ def test_live_adapter_keeps_symlink_refusal_in_existing_issue_taxonomy() -> None
)
def test_live_adapter_reports_an_oversized_file_as_file_too_large() -> None:
"""The open path and the discovery path must agree on the size reason."""
assert _scan_failure_code(SourceAccessErrorCode.FILE_TOO_LARGE) is ScanIssueCode.FILE_TOO_LARGE
def test_live_adapter_runner_persists_symlink_refusal_as_unsupported_schema(
tmp_path, monkeypatch
) -> None:
@@ -3,12 +3,10 @@ from one_memory.imports.access import (
AuthorizedSourceRoot,
SourceAccessErrorCode,
)
from one_memory.imports.adapters import ScanIssueCode
from one_memory.imports.discovery import (
SourceFileDiscoveryFailure,
discover_source_files,
)
from one_memory.imports.sources.common import discovery_failure_code
def grant(root, home):
@@ -62,35 +60,3 @@ def test_discovery_does_not_follow_directory_or_file_symlinks(tmp_path) -> None:
assert len(events) == 1
assert isinstance(events[0], SourceFileDiscoveryFailure)
assert "private" not in events[0].file_key
def test_discovery_reports_an_oversized_file_as_file_too_large(tmp_path) -> None:
"""A size-gate refusal is a size problem, not a schema problem.
The Codex report in #102 shows the access gate refusing a file over the
per-root limit while the UI said `unsupported_schema`, which sends the user
looking for a parser that is working fine.
"""
root = tmp_path / "source"
root.mkdir()
(root / "big.jsonl").write_text('{"content":"too big"}\n')
granted = AuthorizedSourceRoot.grant(
source_id="fixture",
root=root,
user_confirmed=True,
home=tmp_path / "home",
max_file_bytes=8,
)
events = list(
discover_source_files(
granted,
suffixes=frozenset({".jsonl"}),
max_targets=10,
)
)
assert len(events) == 1
assert isinstance(events[0], SourceFileDiscoveryFailure)
assert events[0].reason_code is SourceAccessErrorCode.FILE_TOO_LARGE
assert discovery_failure_code(events[0]) is ScanIssueCode.FILE_TOO_LARGE
+1 -1
View File
@@ -363,7 +363,7 @@ Support is declared per immutable GitHub Release manifest:
- macOS arm64 and x64 App/Runtime artifacts must carry Developer ID signatures,
notarization tickets, and native-platform acceptance receipts.
- Windows is not supported.
- `hy-memory==2.0.4` is the public Python SDK dependency.
- `hy-memory==2.0.3` is the public Python SDK dependency.
See [`INSTALLATION.md`](INSTALLATION.md) for the release matrix. A source test,
fake hosting test, or cross-build does not substitute for a native-platform
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.4`(build 13)的发行目标如下:
MemHub `v1.0.3`(build 7)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
@@ -1,66 +0,0 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { CoreSupervisor } from "./core-supervisor.js";
function fakeChild(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
child.stderr = new EventEmitter() as ChildProcess["stderr"];
child.stdout = new EventEmitter() as ChildProcess["stdout"];
child.kill = vi.fn(() => {
setImmediate(() => child.emit("exit", 0, null));
return true;
});
return child;
}
function fakeReaderChild(): ChildProcess {
return fakeChild();
}
describe("CoreSupervisor reader startup", () => {
it("starts the reader after spawning its own Core", async () => {
const core = fakeChild();
const reader = fakeReaderChild();
const spawned: string[][] = [];
const spawnProcess = vi.fn(
(_command: string, args: readonly string[], _options: SpawnOptions) => {
spawned.push([...args]);
if (args.includes("--reader")) {
// The Reader attaches its stderr listener synchronously after this
// call returns, so publish the port on the next tick.
setImmediate(() =>
reader.stderr?.emit("data", Buffer.from("reader_port:43210\n")),
);
return reader;
}
return core;
},
);
const supervisor = new CoreSupervisor({
launch: { command: "one-memory", args: [] },
dataDir: "/tmp/one-memory-reader-start-test",
instanceId: "owned",
port: 9600,
attempts: 1,
delayMs: 0,
readerEnabled: true,
spawnProcess,
probeHealth: async () => ({
status: "ok",
service: "one-memory-core",
version: "0.0.0",
instance_id: "owned",
}),
});
await supervisor.start();
expect(spawnProcess).toHaveBeenCalledTimes(2);
expect(spawned[1]).toContain("--reader");
expect(supervisor.readerPort).toBe(43210);
await supervisor.stop();
});
});
@@ -339,11 +339,7 @@ export class CoreSupervisor {
}
this.useOwnedIdentity();
try {
const health = await this.attempt();
if (this.options.readerEnabled) {
await this.startReader();
}
return health;
return await this.attempt();
} catch (error) {
// A standalone Runtime may have won the race after our initial
// discovery read but before our child acquired the data lock. Verify it
@@ -351,12 +347,7 @@ export class CoreSupervisor {
let raced: HealthResponse | null = null;
try {
raced = await this.attachExisting();
if (raced !== null) {
if (this.options.readerEnabled) {
await this.startReader();
}
return raced;
}
if (raced !== null) return raced;
} catch (attachError) {
// A stale receipt can fail identity validation after the owned Core
// exited. Treat that as a cleanup candidate, not as a new startup
@@ -396,11 +387,7 @@ export class CoreSupervisor {
this.exitSignal = null;
this.spawnError = null;
try {
const health = await this.attempt();
if (this.options.readerEnabled) {
await this.startReader();
}
return health;
return await this.attempt();
} catch (retryError) {
await this.logStartFailure(retryError);
throw retryError;
@@ -709,9 +696,6 @@ export class CoreSupervisor {
await delay(delayMs);
if (this.stopping) return;
try {
// The old Reader pointed at the Core that just died. Stop it so
// the relaunched Core gets a Reader with the current token/port.
await this.stopReader();
// Keep the configured port stable across relaunches. The packaged
// renderer and discovery receipts point at this port, so moving to
// an ephemeral port would make a recovered Core unreachable.
@@ -722,9 +706,6 @@ export class CoreSupervisor {
this.exited = false;
this.spawnError = null;
const health = await this.attempt();
if (this.options.readerEnabled) {
await this.startReader();
}
await this.options.diagnostics?.log({
level: "info",
component: "runtime.core",
@@ -843,14 +824,6 @@ export class CoreSupervisor {
cwd: this.options.launch.cwd,
});
this.readerChild = child;
// A Reader exits when its Core goes away. Clear the handle so the restart
// path can spawn a fresh one instead of returning early forever.
child.on("exit", () => {
if (this.readerChild === child) {
this.readerChild = null;
this.activeReaderPort = null;
}
});
// Wait briefly for the reader to publish its port on stderr.
this.activeReaderPort = await new Promise<number | null>((resolve) => {
let settled = false;
+2 -2
View File
@@ -12,8 +12,8 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
"utf8",
);
assert.match(readme, /公共 Python SDK 为 `hy-memory==2\.0\.4`/);
assert.match(readme, /`v1\.0\.4` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /公共 Python SDK 为 `hy-memory==2\.0\.3`/);
assert.match(readme, /`v1\.0\.3` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /Linux 制品由 GitHub Actions/);
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
assert.match(readme, /release-manifest\.json/);
@@ -117,19 +117,9 @@ describe("Linux runtime scripts", () => {
workflow,
/verify-release-version\.mjs[\s\S]*--channel stable/,
);
assert.match(workflow, /shopt -s globstar nullglob/);
assert.match(workflow, /archives=\(release-assets\/\*\*\/"\$name"\)/);
assert.match(
workflow,
/cp -- "\$\{archives\[0\]\}" "publish-assets\/\$name"/,
);
assert.match(workflow, /sha256sum -c/);
assert.match(workflow, /--json isDraft[\s\S]*--jq \.isDraft/);
assert.match(workflow, /gh release create[\s\S]*--draft/);
assert.match(workflow, /gh release upload[\s\S]*--clobber/);
assert.match(
workflow,
/publish-assets\/memhub-runtime-v\*-linux-\*\.tar\.gz/,
);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hy-memory"
version = "2.0.5.dev4"
version = "2.0.4"
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
readme = "README.md"
requires-python = ">=3.9"
-19
View File
@@ -1,19 +0,0 @@
from hy_memory.config import MemoryConfig
def test_chain_block_defaults_disabled(monkeypatch):
monkeypatch.delenv("MEMORY_CODING_CHAIN_BLOCK_ENABLED", raising=False)
assert MemoryConfig().coding.chain_block_enabled is False
def test_chain_block_env_can_enable(monkeypatch):
monkeypatch.setenv("MEMORY_CODING_CHAIN_BLOCK_ENABLED", "true")
assert MemoryConfig().coding.chain_block_enabled is True
def test_chain_block_from_dict_can_enable(monkeypatch):
monkeypatch.delenv("MEMORY_CODING_CHAIN_BLOCK_ENABLED", raising=False)
config = MemoryConfig.from_dict({
"coding": {"chain_block_enabled": True},
})
assert config.coding.chain_block_enabled is True
+1 -28
View File
@@ -62,21 +62,7 @@ def _seed_chain(tmpdir, branch="feat/q4", path=_PATH):
return H
def _enable_chain_block(ctx):
"""search 侧 chain_block 默认关;测注入路径时显式 opt-in。"""
from hy_memory.config import MemoryConfig
cfg = getattr(ctx, "_config", None)
if cfg is None:
cfg = MemoryConfig()
ctx._config = cfg
cfg.coding.chain_block_enabled = True
ctx._chain_block_enabled = HyMemoryClient._chain_block_enabled.__get__(
ctx, type(ctx))
return ctx
def _bind_chain(ctx, store, *, enable_block=True):
def _bind_chain(ctx, store):
"""把链层挂到 search_ctx 这个 SimpleNamespace 上。
search_ctx 是裸 namespace,不是真 client,所以 _decorate_with_chain 这个
@@ -87,8 +73,6 @@ def _bind_chain(ctx, store, *, enable_block=True):
ctx._get_chain_store = lambda owner="": store
ctx._decorate_with_chain = HyMemoryClient._decorate_with_chain.__get__(
ctx, type(ctx))
if enable_block:
_enable_chain_block(ctx)
return ctx
@@ -145,17 +129,6 @@ class TestSearchCodingVariantEndToEnd:
assert "第1版" in blk and "第2版" in blk
assert "255.0" in blk and "360.0" in blk
async def test_coding_variant_chain_block_default_off(self, search_ctx, chain_dir):
"""默认不注入 chain_block(省 token);库里有链也返回空块。"""
await self._seed_fact(search_ctx)
# 挂上真链层,但不开启 chain_block_enabled
_bind_chain(search_ctx, _seed_chain(chain_dir), enable_block=False)
resp = await self._search(search_ctx, _STATEMENT, variant="coding")
assert len(resp["facts"]) == 1
assert resp["chain_block"] == ""
assert resp["chain_used"] == []
async def test_facts_not_deduped(self, search_ctx, chain_dir):
"""★ 挂上链的 fact 仍留在 facts 里 —— 刻意不去重。
+3 -24
View File
@@ -326,34 +326,12 @@ class TestChainDecorate:
os.environ.pop("HCM_BLOCK_OFF", None)
def _ns_with_chain_block(**kwargs):
"""胶水测需要显式 opt-in:默认 chain_block_enabled=False 会在进 store 前短路。"""
import types
from hy_memory.config import MemoryConfig
cfg = MemoryConfig()
cfg.coding.chain_block_enabled = True
ns = types.SimpleNamespace(_config=cfg, **kwargs)
ns._chain_block_enabled = HyMemoryClient._chain_block_enabled.__get__(
ns, type(ns))
return ns
class TestDecorateGlue:
"""client._decorate_with_chain —— 胶水层的 fail-safe。"""
def test_returns_empty_when_disabled_by_default(self):
def test_returns_empty_when_chain_unavailable(self):
import types
ns = types.SimpleNamespace(
_chain_store=None, _chain_store_failed=False,
_get_chain_store=lambda owner="": object(),
)
ns._chain_block_enabled = HyMemoryClient._chain_block_enabled.__get__(
ns, type(ns))
assert HyMemoryClient._decorate_with_chain(ns, [dict(_FACT)]) == ("", [])
def test_returns_empty_when_chain_unavailable(self):
ns = _ns_with_chain_block(
_chain_store=None, _chain_store_failed=True,
_get_chain_store=lambda owner="": None,
)
@@ -361,12 +339,13 @@ class TestDecorateGlue:
def test_store_exception_degrades_not_raises(self):
# 这层是增量, 挂了要退回纯向量双层而不是把整次 search 拖失败。
import types
class _Boom:
def decorate(self, *a, **k):
raise RuntimeError("boom")
ns = _ns_with_chain_block(_get_chain_store=lambda owner="": _Boom())
ns = types.SimpleNamespace(_get_chain_store=lambda owner="": _Boom())
assert HyMemoryClient._decorate_with_chain(ns, [dict(_FACT)]) == ("", [])
-13
View File
@@ -1,13 +0,0 @@
# -*- coding: utf-8 -*-
"""Public Abstractor result types must remain constructible dataclasses."""
from hy_memory.agent.abstractor import SessionSummaryResult
def test_session_summary_result_accepts_fields():
result = SessionSummaryResult(success=True, summary="session summary")
assert result.success is True
assert result.summary == "session summary"
assert result.key_topics == []
assert result.error is None
-13
View File
@@ -24,9 +24,6 @@ from hy_memory.data.cache_sqlite import SqliteCache
STATEFUL_METHODS = [
"get_episode_state",
"upsert_episode_state",
"claim_write_outcome",
"finish_write_outcome",
"get_write_outcome",
]
BACKENDS = [
@@ -73,16 +70,6 @@ def test_episode_state_table_declared(backend):
)
@pytest.mark.parametrize("backend", BACKENDS)
def test_write_outcomes_table_declared(backend):
import hy_memory.data.cache_mysql as m
import hy_memory.data.cache_sqlite as s
mod = s if backend is SqliteCache else m
src = inspect.getsource(mod)
assert "CREATE TABLE IF NOT EXISTS write_outcomes" in src
class TestRollingSummaryFullyRemoved:
"""rolling summary 已整体移除,残留引用会让应用在启动路径报错。"""
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""Owned event-loop threads must be stopped and closed with the client."""
from hy_memory.client import HyMemoryClient, _LoopThread
def test_loop_thread_stop_closes_the_loop_and_is_idempotent():
loop_thread = _LoopThread()
loop = loop_thread._loop
loop_thread.stop()
assert not loop_thread._thread.is_alive()
assert loop.is_closed()
# A second stop must not touch a closed loop.
loop_thread.stop()
def test_close_stops_both_loops_owned_by_a_solo_client():
client = HyMemoryClient.__new__(HyMemoryClient)
client._registry = None
client._owns_runtime = True
client._loop_thread = _LoopThread()
client._search_loop_thread = _LoopThread()
loops = [client._loop_thread._loop, client._search_loop_thread._loop]
client.close()
assert all(loop.is_closed() for loop in loops)
def test_shared_runtime_loops_are_left_to_the_runtime():
client = HyMemoryClient.__new__(HyMemoryClient)
client._registry = None
client._owns_runtime = False
client._loop_thread = _LoopThread()
client._search_loop_thread = _LoopThread()
loops = [client._loop_thread._loop, client._search_loop_thread._loop]
try:
client.close()
assert all(not loop.is_closed() for loop in loops)
finally:
client._loop_thread.stop()
client._search_loop_thread.stop()
-125
View File
@@ -1,125 +0,0 @@
from __future__ import annotations
import hashlib
import pytest
from hy_memory.snapshot import (
DataPlaneSnapshotRejected,
restore_local_data_plane,
snapshot_local_data_plane,
)
def _sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_snapshot_copies_and_verifies_local_sdk_tree(tmp_path):
source = tmp_path / "source"
(source / "vector").mkdir(parents=True)
(source / "vector" / "nodes.bin").write_bytes(b"vector")
(source / "cache.sqlite3").write_bytes(b"cache")
target = tmp_path / "snapshot"
result = snapshot_local_data_plane(
source_root=source,
target_root=target,
writers_stopped=True,
)
assert {item["path"] for item in result.files} == {
"cache.sqlite3",
"vector/nodes.bin",
}
for item in result.files:
copied = target / item["path"]
assert copied.stat().st_size == item["bytes"]
assert _sha256(copied) == item["sha256"]
assert result.total_bytes == len(b"vector") + len(b"cache")
def test_snapshot_refuses_active_writers(tmp_path):
source = tmp_path / "source"
source.mkdir()
with pytest.raises(DataPlaneSnapshotRejected) as caught:
snapshot_local_data_plane(
source_root=source,
target_root=tmp_path / "snapshot",
writers_stopped=False,
)
assert caught.value.code == "data_plane_snapshot_writers_running"
def test_snapshot_rejects_symlink_and_removes_partial_target(tmp_path):
source = tmp_path / "source"
source.mkdir()
outside = tmp_path / "outside"
outside.write_text("outside", encoding="utf-8")
try:
(source / "linked").symlink_to(outside)
except OSError:
pytest.skip("symlinks unavailable")
target = tmp_path / "snapshot"
with pytest.raises(DataPlaneSnapshotRejected) as caught:
snapshot_local_data_plane(
source_root=source,
target_root=target,
writers_stopped=True,
)
assert caught.value.code == "data_plane_snapshot_symlink"
assert not target.exists()
def test_snapshot_target_must_not_exist(tmp_path):
source = tmp_path / "source"
target = tmp_path / "snapshot"
source.mkdir()
target.mkdir()
with pytest.raises(DataPlaneSnapshotRejected) as caught:
snapshot_local_data_plane(
source_root=source,
target_root=target,
writers_stopped=True,
)
assert caught.value.code == "data_plane_snapshot_target_exists"
def test_restore_replaces_data_plane_and_keeps_protective_copy(tmp_path):
snapshot = tmp_path / "snapshot"
target = tmp_path / "live"
(snapshot / "vector").mkdir(parents=True)
(snapshot / "vector" / "nodes.bin").write_bytes(b"new")
(target / "vector").mkdir(parents=True)
(target / "vector" / "nodes.bin").write_bytes(b"old")
protective = tmp_path / "previous"
result = restore_local_data_plane(
snapshot_root=snapshot,
target_root=target,
protective_root=protective,
writers_stopped=True,
)
assert (target / "vector" / "nodes.bin").read_bytes() == b"new"
assert (protective / "vector" / "nodes.bin").read_bytes() == b"old"
assert result.previous_root == protective
assert result.files_restored == 1
def test_restore_refuses_active_writers_without_touching_live_data(tmp_path):
snapshot = tmp_path / "snapshot"
target = tmp_path / "live"
snapshot.mkdir()
target.mkdir()
(target / "value").write_text("old", encoding="utf-8")
with pytest.raises(DataPlaneSnapshotRejected) as caught:
restore_local_data_plane(
snapshot_root=snapshot,
target_root=target,
writers_stopped=False,
)
assert caught.value.code == "data_plane_restore_writers_running"
assert (target / "value").read_text(encoding="utf-8") == "old"
-120
View File
@@ -1,120 +0,0 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from hy_memory.client import HyMemoryClient
from hy_memory.config import MemoryConfig
from hy_memory.models.memory import MemoryLayer, MemoryNode
class VectorStore:
_collection_name = "test"
def __init__(self, node=None, *, delete_result=True):
self.node = node
self.delete_result = delete_result
async def get_by_id(self, _memory_id):
return self.node
async def delete(self, _memory_id):
result = self.node is not None and self.delete_result
if result:
self.node = None
return result
async def delete_retrieval_clauses(self, _memory_id):
return None
class GraphStore:
def __init__(self, node=None, *, delete_result=True, lookup_error=False):
self.node = node
self.delete_result = delete_result
self.lookup_error = lookup_error
async def get_node(self, _memory_id):
if self.lookup_error:
raise RuntimeError("graph unavailable")
return self.node
async def delete_node(self, _memory_id):
if self.delete_result:
self.node = None
return self.delete_result
def _node():
return MemoryNode(
node_id="m1",
user_id="u1",
agent_id="a1",
session_id="s1",
content="body",
layer=MemoryLayer.L2_FACT,
)
def _client(vector, graph):
client = HyMemoryClient.__new__(HyMemoryClient)
client._vector_store = vector
client._graph_store = graph
client._history_store = None
client._config = MemoryConfig.from_dict(
{"vector_store": {"provider": "faiss", "collection_name": "test"}}
)
client._maybe_store_op_log = AsyncMock()
client._cleanup_graph_evidence = AsyncMock(return_value=True)
return client
@pytest.mark.asyncio
async def test_delete_succeeds_only_when_every_existing_store_is_deleted():
client = _client(VectorStore(_node()), GraphStore(_node()))
result = await HyMemoryClient.async_delete.__wrapped__(client, "m1")
assert result["success"] is True
assert result["deleted_count"] == 1
assert result["vdb_deleted"] is True
assert result["graph_deleted"] is True
assert result["partial"] is False
assert result["error_code"] is None
@pytest.mark.asyncio
async def test_graph_failure_is_reported_as_partial_not_success():
client = _client(
VectorStore(_node()),
GraphStore(_node(), delete_result=False),
)
result = await HyMemoryClient.async_delete.__wrapped__(client, "m1")
assert result["success"] is False
assert result["deleted_count"] == 0
assert result["vdb_deleted"] is True
assert result["graph_deleted"] is False
assert result["partial"] is True
assert result["error_code"] == "delete_incomplete"
@pytest.mark.asyncio
async def test_graph_only_memory_can_be_physically_deleted():
client = _client(VectorStore(None), GraphStore(_node()))
result = await HyMemoryClient.async_delete.__wrapped__(client, "m1")
assert result["success"] is True
assert result["vdb_deleted"] is False
assert result["graph_deleted"] is True
assert result["deleted_count"] == 1
@pytest.mark.asyncio
async def test_graph_lookup_failure_never_collapses_to_not_found_success():
client = _client(VectorStore(None), GraphStore(lookup_error=True))
result = await HyMemoryClient.async_delete.__wrapped__(client, "m1")
assert result["success"] is False
assert result["partial"] is False
assert result["error_code"] == "delete_incomplete"
-52
View File
@@ -173,41 +173,6 @@ class TestEpisodeCloseTriggersSummary:
await w._resolve_episode_id(_req(t0 + timedelta(hours=3)), 3)
assert scheduled == [ep1], "只在关闭时对刚关闭的那个 episode 触发一次"
@pytest.mark.asyncio
async def test_summary_uses_the_writer_vector_store(self, monkeypatch):
"""后台摘要必须拿到 writer 已初始化的 vector store。
旧实现访问了不存在的 ``self.vector_store``,AttributeError 被
except 吞掉,L3 episode 摘要从不生成。
"""
from types import SimpleNamespace
import hy_memory.agent.episode_summary as episode_summary
captured = {}
async def fake_summarize(**kwargs):
captured.update(kwargs)
return "episode-node"
monkeypatch.setattr(
episode_summary, "summarize_closed_episode", fake_summarize,
)
w = _writer(_FakeCache())
w.config.recall.episode_summary_enabled = True
w._mem_agent = SimpleNamespace(llm_provider=object())
w._embed_service = object()
vector_store = object()
w._external_vector_store = vector_store
w._vector_store = vector_store
w._vector_store_initialized = True
w._schedule_episode_summary(_req(datetime(2026, 9, 13)), "ep-1")
await asyncio.sleep(0.05)
assert captured["vector_store"] is vector_store
# ======================================================================
# Episode 摘要(写侧,省 token)
@@ -710,20 +675,3 @@ class TestInstrumentation:
assert logged[1]["count"] == 5, "累计条数要落下来"
assert logged[2]["reused"] is False
assert all(r["gap"] == 1800.0 for r in logged)
def test_series_memory_at_round_trips_local_wall_clock(monkeypatch):
"""Series timestamps must not shift by the machine's UTC offset."""
import time as _time
from hy_memory.agent.episode_summary import _series_memory_at
monkeypatch.setenv("TZ", "Asia/Shanghai")
_time.tzset()
try:
expected = datetime(2026, 1, 1, 12, 0)
assert _series_memory_at([int(expected.timestamp())]) == expected
assert _series_memory_at([]) is None
finally:
monkeypatch.delenv("TZ", raising=False)
_time.tzset()
-57
View File
@@ -1,57 +0,0 @@
# -*- coding: utf-8 -*-
"""FAISS 后端的 get_embeddings 必须能取回普通 upsert 写入的向量。
``_data`` 只保存 payload 与内部下标,向量本体在 FAISS index 里。去重和
episode 摘要依赖 get_embeddings,取不到向量会让这些能力静默失效。
"""
import asyncio
import pytest
pytest.importorskip("faiss")
from hy_memory.config import MemoryConfig # noqa: E402
from hy_memory.data.vector_store_faiss import FaissVectorStore # noqa: E402
from hy_memory.models.memory import MemoryLayer, MemoryNode # noqa: E402
def _config(tmp_path):
return MemoryConfig.from_dict({
"vector_store": {
"provider": "faiss",
"collection_name": "get_embeddings_test",
"persist_directory": str(tmp_path / "faiss"),
"embedding_dims": 4,
},
})
def test_get_embeddings_reconstructs_upserted_and_updated_vectors(tmp_path):
async def _run():
store = FaissVectorStore(_config(tmp_path))
await store.initialize()
try:
node = MemoryNode(
user_id="u1",
agent_id="a1",
session_id="s1",
source="default",
layer=MemoryLayer.L1_RAW,
content="hello",
embedding=[1.0, 0.0, 0.0, 0.0],
)
await store.upsert(node)
got = await store.get_embeddings([node.node_id])
assert got[node.node_id] == pytest.approx([1.0, 0.0, 0.0, 0.0])
assert await store.update_embedding(node.node_id, [0.0, 1.0, 0.0, 0.0])
got = await store.get_embeddings([node.node_id])
assert got[node.node_id] == pytest.approx([0.0, 1.0, 0.0, 0.0])
assert await store.get_embeddings(["missing"]) == {}
finally:
await store.close()
asyncio.run(_run())
-60
View File
@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
"""History queries without agent_ids must cover every agent of the user."""
import asyncio
from hy_memory.config import MemoryConfig
from hy_memory.data.history_store import HistoryStore
def _store(tmp_path):
config = MemoryConfig()
config.history.db_path = str(tmp_path / "history.db")
return HistoryStore(config)
def test_get_recent_for_user_matches_all_agents(tmp_path):
async def _run():
store = _store(tmp_path)
await store.initialize()
try:
keys = [
"u1:default_agent",
"u1:agent_b",
"u10:default_agent",
"u2:default_agent",
]
for index, key in enumerate(keys):
await store.record_add(
memory_id=f"m{index}",
content="x",
layer="l1_raw",
isolation_key=key,
)
rows = await store.get_recent_for_user("u1", limit=10)
finally:
await store.close()
return [row["isolation_key"] for row in rows]
assert set(asyncio.run(_run())) == {"u1:default_agent", "u1:agent_b"}
def test_get_recent_for_user_escapes_like_wildcards(tmp_path):
async def _run():
store = _store(tmp_path)
await store.initialize()
try:
await store.record_add(
memory_id="m1", content="x", layer="l1_raw",
isolation_key="u_1:default_agent",
)
await store.record_add(
memory_id="m2", content="x", layer="l1_raw",
isolation_key="uX1:default_agent",
)
rows = await store.get_recent_for_user("u_1", limit=10)
finally:
await store.close()
return [row["isolation_key"] for row in rows]
assert asyncio.run(_run()) == ["u_1:default_agent"]
+3 -76
View File
@@ -31,12 +31,7 @@ def run(coro):
DIMS = 32
def _node(
user_id="u1",
content="x",
layer=MemoryLayer.L2_FACT,
status=MemoryStatus.ACTIVE,
):
def _node(user_id="u1", content="x", layer=MemoryLayer.L2_FACT):
import random
n = MemoryNode(
node_id=str(uuid.uuid4()),
@@ -45,8 +40,8 @@ def _node(
session_id="s1",
content=content,
layer=layer,
status=status,
is_latest=status == MemoryStatus.ACTIVE,
status=MemoryStatus.ACTIVE,
is_latest=True,
)
vec = [random.gauss(0, 1) for _ in range(DIMS)]
norm = sum(v * v for v in vec) ** 0.5
@@ -109,22 +104,6 @@ class TestNormalizeLayers:
HyMemoryClient._normalize_layers(["l3_identity"])
class TestNormalizeStatuses:
def test_none_and_empty_preserve_active_default(self):
assert HyMemoryClient._normalize_statuses(None) is None
assert HyMemoryClient._normalize_statuses([]) is None
def test_string_and_enum_values_are_deduplicated(self):
got = HyMemoryClient._normalize_statuses(
["superseded", MemoryStatus.SHADOW, "superseded"]
)
assert got == [MemoryStatus.SUPERSEDED, MemoryStatus.SHADOW]
def test_invalid_status_raises(self):
with pytest.raises(ValueError):
HyMemoryClient._normalize_statuses(["deleted"])
class TestVdbBucketLayerFilter:
def _seed(self, store):
run(store.upsert_batch([
@@ -184,24 +163,6 @@ class TestVdbBucketLayerFilter:
))
assert [m["content"] for m in got["memories"]] == ["fact"]
def test_status_filter_defaults_to_active_and_can_read_superseded(self, faiss_store):
run(faiss_store.upsert_batch([
_node(content="active", status=MemoryStatus.ACTIVE),
_node(content="old", status=MemoryStatus.SUPERSEDED),
_node(content="hidden", status=MemoryStatus.SHADOW),
]))
default_page = run(_client_with(faiss_store)._list_vdb_bucket(
user_id="u1", agent_id=None, limit=100, offset=0, order="desc",
))
assert [m["content"] for m in default_page["memories"]] == ["active"]
lifecycle_page = run(_client_with(faiss_store)._list_vdb_bucket(
user_id="u1", agent_id=None, limit=100, offset=0, order="desc",
statuses=[MemoryStatus.SUPERSEDED, MemoryStatus.SHADOW],
))
assert {m["content"] for m in lifecycle_page["memories"]} == {"old", "hidden"}
def test_layer_filter_composes_with_multi_user(self, faiss_store):
"""R3 与 R1 叠加:多用户 + 层级筛选同时生效。"""
run(faiss_store.upsert_batch([
@@ -254,37 +215,3 @@ class TestGraphBucketLayerSkip:
user_id="u1", agent_id=None, limit=10, offset=0, order="desc",
))
assert calls == [1], "不传 layers 时行为与改动前一致"
def test_graph_status_filter_matches_vdb_contract(self):
active = _node(content="active graph", layer=MemoryLayer.L6_SCHEMA)
old = _node(
content="old graph",
layer=MemoryLayer.L6_SCHEMA,
status=MemoryStatus.SUPERSEDED,
)
class GraphStore:
async def get_all_nodes(self, *_args, **_kwargs):
return [active, old]
async def close(self):
return None
c = HyMemoryClient.__new__(HyMemoryClient)
async def _acquire():
return GraphStore(), True
c._acquire_graph_store_for_purge = _acquire
default_page = run(c._list_graph_bucket(
user_id="u1", agent_id=None, limit=10, offset=0, order="desc",
layers=[MemoryLayer.L6_SCHEMA],
))
assert [item["content"] for item in default_page["nodes"]] == ["active graph"]
old_page = run(c._list_graph_bucket(
user_id="u1", agent_id=None, limit=10, offset=0, order="desc",
layers=[MemoryLayer.L6_SCHEMA],
statuses=[MemoryStatus.SUPERSEDED],
))
assert [item["content"] for item in old_page["nodes"]] == ["old graph"]
-111
View File
@@ -1,111 +0,0 @@
# -*- coding: utf-8 -*-
"""MySQL minute metrics must merge under one row lock."""
import asyncio
import json
import pytest
pytest.importorskip("aiomysql")
from hy_memory.config import MemoryConfig # noqa: E402
from hy_memory.data.cache_mysql import MysqlCache # noqa: E402
class FakeCursor:
def __init__(self, row):
self.row = row
self.executed = []
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def execute(self, sql, args=None):
self.executed.append((sql, args))
async def fetchone(self):
return self.row
class FakeConnection:
def __init__(self, row):
self.cursor_obj = FakeCursor(row)
self.begun = False
self.committed = False
self.rolled_back = False
async def begin(self):
self.begun = True
async def commit(self):
self.committed = True
async def rollback(self):
self.rolled_back = True
def cursor(self, *args, **kwargs):
return self.cursor_obj
class _Acquire:
def __init__(self, connection):
self.connection = connection
async def __aenter__(self):
return self.connection
async def __aexit__(self, *args):
return None
class FakePool:
def __init__(self, connection):
self.connection = connection
def acquire(self):
return _Acquire(self.connection)
def _cache_with(row):
cache = MysqlCache(MemoryConfig())
connection = FakeConnection(row)
cache._pool = FakePool(connection)
return cache, connection
def test_metrics_insert_takes_the_row_lock_and_commits():
cache, connection = _cache_with(None)
asyncio.run(
cache.store_metrics_minute("2026-01-01T00:00", {"sys1_started": 2})
)
statements = [sql for sql, _ in connection.cursor_obj.executed]
assert any("FOR UPDATE" in sql for sql in statements)
assert any(sql.startswith("INSERT") for sql in statements)
assert connection.begun and connection.committed
assert not connection.rolled_back
def test_metrics_update_merges_existing_buckets():
old = {"sys1_started": 1, "sys1_timing_sums": {"a": 1}}
cache, connection = _cache_with({"data": json.dumps(old)})
asyncio.run(
cache.store_metrics_minute(
"2026-01-01T00:00",
{"sys1_started": 2, "sys1_timing_sums": {"a": 3}},
)
)
update = next(
args for sql, args in connection.cursor_obj.executed
if sql.startswith("UPDATE")
)
merged = json.loads(update[0])
assert merged["sys1_started"] == 3
assert merged["sys1_timing_sums"]["a"] == 4
assert connection.committed
-405
View File
@@ -1,405 +0,0 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from hy_memory.client import HyMemoryClient
from hy_memory.config import MemoryConfig
from hy_memory.models.memory import (
MemoryLayer,
MemoryNode,
MemoryStatus,
SourceType,
)
from hy_memory.pipelines.base import WriteRequest
from hy_memory.pipelines.writer import MemoryWriter
class OutcomeCache:
def __init__(self):
self.rows = {}
async def claim_write_outcome(
self, request_id, user_id, agent_id, session_id, operation
):
if request_id in self.rows:
return self.rows[request_id]
self.rows[request_id] = {
"request_id": request_id,
"user_id": user_id,
"agent_id": agent_id,
"session_id": session_id,
"operation": operation,
"status": "running",
"result": {},
}
return None
async def finish_write_outcome(self, request_id, status, result):
self.rows[request_id].update(status=status, result=dict(result))
return True
async def get_write_outcome(self, request_id):
return self.rows.get(request_id)
class RawStore:
def __init__(self):
self.nodes = {}
self.upserts = 0
self.supports_fulltext = False
async def get_by_id(self, node_id):
return self.nodes.get(node_id)
async def initialize(self):
return None
async def upsert(self, node):
self.upserts += 1
self.nodes[node.node_id] = node
return node.node_id
async def update_payload(self, node_id, payload):
node = self.nodes[node_id]
for key, value in payload.items():
setattr(node, key, value)
async def list_by_user(
self,
*,
user_id,
agent_id=None,
include_sources=None,
**_kwargs,
):
return [
node
for node in self.nodes.values()
if node.user_id == user_id
and (agent_id is None or node.agent_id == agent_id)
and (not include_sources or node.source in include_sources)
]
async def search(
self,
*,
user_ids,
agent_ids,
include_sources,
exclude_sources,
layers,
limit,
**_kwargs,
):
rows = []
for node in self.nodes.values():
if node.user_id not in user_ids:
continue
if agent_ids and node.agent_id not in agent_ids:
continue
if include_sources and node.source not in include_sources:
continue
if exclude_sources and node.source in exclude_sources:
continue
if node.layer not in layers:
continue
rows.append({"node": node, "score": 0.9})
return rows[:limit]
def _client():
client = HyMemoryClient.__new__(HyMemoryClient)
client._cache = OutcomeCache()
client._vector_store = RawStore()
client._embed_service = AsyncMock()
client._embed_service.embed.return_value = [0.1, 0.2]
return client
@pytest.mark.asyncio
async def test_import_raw_preserves_identity_without_extraction():
client = _client()
result = await HyMemoryClient.async_import_raw.__wrapped__(
client,
"verbatim",
user_id="u1",
agent_id="a1",
session_id="s1",
source="cursor",
raw_id="raw-1",
request_id="request-1",
metadata={"role": "user", "source_order": 7},
)
assert result == {
"success": True,
"raw_id": "raw-1",
"request_id": "request-1",
}
node = client._vector_store.nodes["raw-1"]
assert node.layer == MemoryLayer.L1_RAW
assert node.content == "verbatim"
assert node.custom["role"] == "user"
assert node.custom["source_order"] == 7
assert node.custom["input_kind"] == "raw_exact_import"
@pytest.mark.asyncio
async def test_import_raw_request_replay_does_not_write_twice():
client = _client()
first = await HyMemoryClient.async_import_raw.__wrapped__(
client, "same", user_id="u1", raw_id="raw-1", request_id="request-1"
)
second = await HyMemoryClient.async_import_raw.__wrapped__(
client, "same", user_id="u1", raw_id="raw-1", request_id="request-1"
)
assert second == {**first, "replayed": True}
assert client._vector_store.upserts == 1
@pytest.mark.asyncio
async def test_process_raw_is_idempotent_and_does_not_insert_raw_again():
client = _client()
for index, role in enumerate(("user", "assistant"), start=1):
await HyMemoryClient.async_import_raw.__wrapped__(
client,
f"turn-{index}",
user_id="u1",
agent_id="a1",
session_id="s1",
source="cursor",
raw_id=f"raw-{index}",
request_id=f"import-{index}",
metadata={"role": role, "source_order": index},
)
client._async_add_impl = AsyncMock(
return_value={"success": True, "memory_ids": ["memory-1"]}
)
first = await HyMemoryClient.async_process_raw.__wrapped__(
client,
["raw-1", "raw-2"],
user_id="u1",
agent_id="a1",
session_id="s1",
source="cursor",
request_id="process-1",
)
second = await HyMemoryClient.async_process_raw.__wrapped__(
client,
["raw-1", "raw-2"],
user_id="u1",
agent_id="a1",
session_id="s1",
source="cursor",
request_id="process-1",
)
assert first["raw_ids"] == ["raw-1", "raw-2"]
assert first["memory_ids"] == ["memory-1"]
assert second == {**first, "replayed": True}
assert client._vector_store.upserts == 2
client._async_add_impl.assert_awaited_once()
assert client._async_add_impl.await_args.kwargs["_existing_raw_ids"] == [
"raw-1",
"raw-2",
]
@pytest.mark.asyncio
async def test_writer_reuses_existing_raw_without_embedding_or_upsert():
store = RawStore()
raw = MemoryNode(
node_id="raw-1",
user_id="u1",
agent_id="a1",
session_id="s1",
source="cursor",
layer=MemoryLayer.L1_RAW,
content="verbatim",
source_type=SourceType.EXPLICIT,
status=MemoryStatus.ACTIVE,
is_latest=True,
embedding=[0.1, 0.2],
)
store.nodes[raw.node_id] = raw
embed = AsyncMock()
writer = MemoryWriter(
MemoryConfig(),
embed_service=embed,
vector_store=store,
cache=None,
)
response = await writer.write(
WriteRequest(
content="[user]: verbatim",
user_id="u1",
agent_id="a1",
session_id="s1",
source="cursor",
request_id="process-1",
existing_raw_ids=["raw-1"],
extra={
"agent_mode": "disabled",
},
)
)
assert response.success is True
assert response.memory_id == "raw-1"
assert store.upserts == 0
embed.embed_queued_with_meta.assert_not_awaited()
@pytest.mark.asyncio
async def test_existing_raw_id_must_match_exact_content_and_owner():
client = _client()
await HyMemoryClient.async_import_raw.__wrapped__(
client, "first", user_id="u1", raw_id="raw-1", request_id="request-1"
)
with pytest.raises(ValueError, match="different source content"):
await HyMemoryClient.async_import_raw.__wrapped__(
client, "changed", user_id="u1", raw_id="raw-1", request_id="request-2"
)
@pytest.mark.asyncio
async def test_raw_get_and_list_use_explicit_api():
client = _client()
for raw_id, session_id, source in (
("raw-1", "s1", "cursor"),
("raw-2", "s2", "claude"),
):
await HyMemoryClient.async_import_raw.__wrapped__(
client,
raw_id,
user_id="u1",
session_id=session_id,
source=source,
raw_id=raw_id,
request_id=f"request-{raw_id}",
)
item = await HyMemoryClient.async_get_raw.__wrapped__(client, "raw-1")
assert item["raw_id"] == "raw-1"
assert item["content"] == "raw-1"
page = await HyMemoryClient.async_list_raw.__wrapped__(
client,
user_id="u1",
source="claude",
limit=10,
offset=0,
)
assert page["total"] == 1
assert [item["raw_id"] for item in page["raw"]] == ["raw-2"]
@pytest.mark.asyncio
async def test_exact_memory_import_preserves_id_lifecycle_and_chain():
client = _client()
result = await HyMemoryClient.async_import_memory_exact.__wrapped__(
client,
"older preference",
memory_id="memory-1",
user_id="u1",
agent_id="a1",
session_id="s1",
source="cursor",
layer="l2_fact",
status="superseded",
owner="user",
tags=["preference"],
superseded_by=["memory-2"],
is_latest=False,
request_id="request-memory-1",
)
assert result["memory_ids"] == ["memory-1"]
node = client._vector_store.nodes["memory-1"]
assert node.content == "older preference"
assert node.layer == MemoryLayer.L2_FACT
assert node.status == MemoryStatus.SUPERSEDED
assert node.superseded_by == ["memory-2"]
assert node.is_latest is False
assert node.custom["input_kind"] == "memory_exact_import"
@pytest.mark.asyncio
async def test_exact_memory_import_refuses_same_id_with_different_body():
client = _client()
await HyMemoryClient.async_import_memory_exact.__wrapped__(
client,
"first",
memory_id="memory-1",
user_id="u1",
request_id="request-memory-1",
)
with pytest.raises(ValueError, match="different memory content"):
await HyMemoryClient.async_import_memory_exact.__wrapped__(
client,
"changed",
memory_id="memory-1",
user_id="u1",
request_id="request-memory-2",
)
@pytest.mark.asyncio
async def test_exact_memory_import_rejects_raw_layer():
client = _client()
with pytest.raises(ValueError, match="use import_raw"):
await HyMemoryClient.async_import_memory_exact.__wrapped__(
client,
"raw",
memory_id="memory-1",
user_id="u1",
layer="l1_raw",
request_id="request-memory-1",
)
@pytest.mark.asyncio
async def test_existing_search_contract_can_explicitly_search_raw_with_source_filter():
client = _client()
for raw_id, session_id, source in (
("raw-1", "s1", "cursor"),
("raw-2", "s2", "claude"),
):
await HyMemoryClient.async_import_raw.__wrapped__(
client,
f"content {raw_id}",
user_id="u1",
agent_id="a1",
session_id=session_id,
source=source,
raw_id=raw_id,
request_id=f"request-{raw_id}",
)
result = await client._search_explicit_raw_layer(
"content",
user_ids=["u1"],
agent_ids=["a1"],
session_ids=["s2"],
include_sources=["claude"],
exclude_sources=["cursor"],
limit=5,
min_score=0.0,
created_after=None,
request_id="search-1",
t0=0.0,
query_embedding=[0.1, 0.2],
)
assert [item["memory_id"] for item in result["memories"]["normal"]] == [
"raw-2"
]
assert result["memories"]["normal"][0]["layer"] == "l1_raw"
assert result["memories"]["normal"][0]["source"] == "claude"
-22
View File
@@ -77,28 +77,6 @@ class TestReadRequestWiring:
HyMemoryClient._normalize_layers(["l3_identity"])
class TestDedupKindPairing:
"""dedup 的 _memory_kind 必须与它的 public item 来自同一条 reader 结果。"""
def test_memory_kind_follows_source_after_layer_filter(self):
memories = [
{"memory_id": "raw", "layer": "l1_raw", "content": "raw"},
{
"memory_id": "canon",
"layer": "l2_fact",
"content": "canon",
"_memory_kind": "canonical_state",
},
]
all_mems, dedup_mems = HyMemoryClient._public_search_views(
memories,
wanted_layers={"l2_fact"},
lite_mode=False,
)
assert [m["memory_id"] for m in all_mems] == ["canon"]
assert dedup_mems[0]["_memory_kind"] == "canonical_state"
class TestHybridDirectedRecall:
"""共享 reader 主流程必须把 request.layers 下推到召回阶段。"""
-76
View File
@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
"""HTTP server signal shutdown must not deadlock the serving thread."""
import subprocess
import sys
import textwrap
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from hy_memory.server import _request_shutdown
class _Handler(BaseHTTPRequestHandler):
def do_GET(self): # pragma: no cover - the test only needs a live server
self.send_response(200)
self.end_headers()
def log_message(self, *args): # pragma: no cover - silence test output
pass
def test_request_shutdown_returns_promptly():
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
server.daemon_threads = True
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
started = time.monotonic()
_request_shutdown(server)
assert time.monotonic() - started < 1.0
thread.join(timeout=2.0)
assert not thread.is_alive()
finally:
server.server_close()
def test_signal_handler_does_not_deadlock_the_serving_thread():
"""A signal handler calling shutdown() directly hangs forever.
Run the serving loop on the main thread (as run_server does), fire an
alarm, and require the process to exit on its own.
"""
script = textwrap.dedent(
"""
import signal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from hy_memory.server import _request_shutdown
class H(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), H)
def _handler(signum, frame):
_request_shutdown(server)
signal.signal(signal.SIGALRM, _handler)
signal.alarm(1)
server.serve_forever()
server.server_close()
print("exited")
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0, result.stderr
assert "exited" in result.stdout
-47
View File
@@ -1,47 +0,0 @@
from __future__ import annotations
import pytest
from hy_memory.config import MemoryConfig
from hy_memory.data.cache_sqlite import SqliteCache
@pytest.fixture
async def cache(tmp_path):
config = MemoryConfig.from_dict(
{"cache": {"backend": "sqlite", "db_path": str(tmp_path / "cache.sqlite3")}}
)
value = SqliteCache(config)
await value.initialize()
yield value
await value.close()
@pytest.mark.asyncio
async def test_claim_is_atomic_and_returns_existing_scope(cache):
claimed = await cache.claim_write_outcome("r1", "u1", "a1", "s1", "add")
assert claimed is None
existing = await cache.claim_write_outcome("r1", "u1", "a1", "s1", "add")
assert existing["status"] == "running"
assert existing["user_id"] == "u1"
assert existing["operation"] == "add"
@pytest.mark.asyncio
async def test_terminal_result_round_trips(cache):
await cache.claim_write_outcome("r2", "u1", "a1", "s1", "add_extracted")
assert await cache.finish_write_outcome(
"r2",
"succeeded",
{"success": True, "memory_ids": ["m1", "m2"]},
)
outcome = await cache.get_write_outcome("r2")
assert outcome["status"] == "succeeded"
assert outcome["result"] == {"success": True, "memory_ids": ["m1", "m2"]}
@pytest.mark.asyncio
async def test_missing_outcome_is_distinct_from_running(cache):
assert await cache.get_write_outcome("missing") is None
-128
View File
@@ -1,128 +0,0 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from hy_memory.client import HyMemoryClient
class OutcomeCache:
def __init__(self):
self.rows: dict[str, dict] = {}
async def claim_write_outcome(
self,
request_id,
user_id,
agent_id,
session_id,
operation,
):
existing = self.rows.get(request_id)
if existing is not None:
return existing
self.rows[request_id] = {
"request_id": request_id,
"user_id": user_id,
"agent_id": agent_id,
"session_id": session_id,
"operation": operation,
"status": "running",
"result": {},
}
return None
async def finish_write_outcome(self, request_id, status, result):
row = self.rows.get(request_id)
if row is None:
return False
row.update(status=status, result=dict(result))
return True
async def get_write_outcome(self, request_id):
return self.rows.get(request_id)
def _client():
client = HyMemoryClient.__new__(HyMemoryClient)
client._cache = OutcomeCache()
return client
@pytest.mark.asyncio
async def test_same_request_replays_terminal_result_without_writing_twice():
client = _client()
client._async_add_impl = AsyncMock(
return_value={"success": True, "request_id": "r1", "memory_id": "m1"}
)
first = await HyMemoryClient.async_add.__wrapped__(
client, "hello", user_id="u1", request_id="r1"
)
second = await HyMemoryClient.async_add.__wrapped__(
client, "hello", user_id="u1", request_id="r1"
)
assert first["memory_id"] == "m1"
assert second == {**first, "replayed": True}
client._async_add_impl.assert_awaited_once()
@pytest.mark.asyncio
async def test_exception_marks_outcome_unknown_and_blocks_blind_retry():
client = _client()
client._async_add_impl = AsyncMock(side_effect=RuntimeError("after commit is unknown"))
with pytest.raises(RuntimeError):
await HyMemoryClient.async_add.__wrapped__(
client, "hello", user_id="u1", request_id="r2"
)
replay = await HyMemoryClient.async_add.__wrapped__(
client, "hello", user_id="u1", request_id="r2"
)
assert replay["success"] is False
assert replay["outcome_status"] == "unknown"
assert replay["error_code"] == "write_outcome_not_terminal"
client._async_add_impl.assert_awaited_once()
@pytest.mark.asyncio
async def test_request_id_cannot_cross_write_scope():
client = _client()
client._async_add_impl = AsyncMock(
return_value={"success": True, "request_id": "r3", "memory_id": "m1"}
)
await HyMemoryClient.async_add.__wrapped__(
client, "hello", user_id="u1", agent_id="a1", request_id="r3"
)
with pytest.raises(ValueError, match="another write scope"):
await HyMemoryClient.async_add.__wrapped__(
client, "hello", user_id="u2", agent_id="a1", request_id="r3"
)
@pytest.mark.asyncio
async def test_add_extracted_uses_the_same_idempotent_outcome_contract():
client = _client()
client._async_add_extracted_impl = AsyncMock(
return_value={
"success": True,
"request_id": "r4",
"memory_id": "m1",
"memory_ids": ["m1"],
}
)
first = await HyMemoryClient.async_add_extracted.__wrapped__(
client, ["fact"], user_id="u1", request_id="r4"
)
second = await HyMemoryClient.async_add_extracted.__wrapped__(
client, ["fact"], user_id="u1", request_id="r4"
)
assert first["memory_ids"] == ["m1"]
assert second == {**first, "replayed": True}
client._async_add_extracted_impl.assert_awaited_once()
Generated
+1 -1
View File
@@ -1715,7 +1715,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.5.dev4"
version = "2.0.4"
source = { editable = "." }
dependencies = [
{ name = "chromadb" },
-70
View File
@@ -1,70 +0,0 @@
# 新鲜度(memory evidence freshness)拆除 TODO
状态:PR1 已开(读路径短路)——分支 `chore/retire-memory-freshness`。#86 不合并。
## 结论
- 新鲜度 = 写记忆时记下 `support_refs` 指纹,读时在 MemHub SQLite 里逐条核对 evidence 是否仍匹配;对不上则滤掉该 memory。
- 这是过度设计:读路径成本高、失败面大(曾导致记忆库整页 503),收益仅是「证据被改过就藏记忆」。
- 业务层为喂 SDK 再做长证据分段(`segment_*`),与 SDK 自身阶段/窗口处理重复;#86 只是继续兼容这套 proof,不应合入。
## 范围(MemHub Core,非 SDK)
权威库:`~/.one-memory/one-memory.sqlite3`
不涉及 SDK `hy-memory/**` 内部 store。
### 读路径(优先拆)
| 位置 | 作用 |
|---|---|
| `memhub/apps/core/src/one_memory/memory_evidence_freshness.py` | UDF `is_memory_evidence_current`、proof prefetch、预算跳过 |
| `source_read_access.py` | 列表/过滤时 per-memory freshness |
| `context_memory.py` | search/context 候选行 `_memory_row_is_fresh` |
| `memory_library.py` / `home_summary.py` | 记忆库 / 首页可读谓词 |
| `memory_visibility.py` | 可见性缓存与 freshness 注册 |
| `call_event_references.py` | 引用解析时的 freshness |
### 写路径 / 数据(后拆,可分 PR)
| 位置 | 作用 |
|---|---|
| `memory_tasks.py` → `MemoryTaskOutput.support_refs` / `TaskEvidenceRef` | 抽取输出里的证明 |
| `memory_task_outputs` 表 | proof 持久化 |
| `memory_processing.py` 分段(`segment_evidence_record` / `_semantic_segment_spans`) | 为 HY 窗口切段;与 freshness 耦合但不全是 freshness——分段是否保留需单独拍板 |
### 测试(随拆除改/删)
- `tests/test_memory_evidence_freshness.py`
- `tests/test_memory_freshness_integration_review.py`
- `tests/test_read_path_freshness_batch.py`
- 以及 mock `register_memory_freshness` 的 library / context / visibility / home_summary / call_event 测试
## 建议落地步骤
1. **读路径短路(最小 PR)** — 本 PR
`register_memory_freshness` 恒返回 `"1"`(UDF 恒 `1`),`freshness_proofs_prefetched` 为空操作。行为:证据改过也不再藏 memory;畸形/segment proof 不再 503。
同步改「期望 stale=0」的单测为「始终可读」。
2. **拆调用点**
去掉 `AND is_memory_evidence_current(...)`、`freshness_proofs_prefetched`、`_memory_row_is_fresh` 等死代码,避免假接口长期残留。
3. **停写 / 弃用 proof(可选第二 PR)**
新任务不再依赖 `support_refs` 做读决策;表与历史行可先保留只读,再决定是否 migration 清理。
4. **分段策略另议**
长证据切段是 ingest 窗口问题,不是新鲜度问题。拆除 freshness 后单独决定:继续由 MemHub 切 messages,还是交给 SDK。
5. **对外沟通**
- #86:已留言不合并、将拆除设计。
- 相关 issue(如 #74 里 freshness 503)关闭或改指向「拆除」而非「修 segment」。
## 验收
- 记忆库 / search / context 在存在「证据已修订 + 旧 proof」时仍返回该 memory,且不再因 malformed/segment ref 503。
- 无 `ONE_MEMORY_FRESHNESS_*` 行为依赖;文档/env 若有提及一并删。
- Core 相关测试绿。
## 非目标
- 不改 SDK 内部抽取/存储。
- 不在本轮做「用别的一致性模型替换 freshness」;先删,再按需重设计。