Compare commits

...
Author SHA1 Message Date
kristinhe ecfd9c73bc build(sdk): package the repo SDK so snapshot and log fixes reach the app
build 18 只带进了 core 侧的改动。core 依赖的是 PyPI 上的 hy-memory==2.0.5,
仓库 hy_memory/ 下的改动(快照排除 logs、日志保留 3 天、初始化日志降级)
根本没有进包 —— 挂载 dmg 读 core/_internal/hy_memory 可证:log_setup 仍是
retention_days=15,snapshot 全文没有剪枝。

- 根 pyproject: hy-memory 2.0.5 -> 2.0.6
- core pyproject: 依赖 2.0.6,并加 [tool.uv.sources] 指向仓库根,让
  `uv run --directory apps/core` 用本地源码构建 SDK;2.0.6 正式发布后删掉该段即可
- uv.lock: 重新解析,hy-memory 由 registry 改为 path 源(其余包版本未动)
- build number 18 -> 19,避免与已交付的 build 18 同号不同字节

实测:uv sync 后 site-packages/hy_memory 为 2.0.6,retention 默认 3、
SNAPSHOT_EXCLUDED_TOP_LEVEL={'logs'}、初始化日志为 debug。
2026-10-24 00:05:42 +08:00
kristinhe 899864c696 chore(release): bump MemHub build number to 18
本包代码与 build 17 的 commit 不同但字节不同,沿用 17 会触发
local-install 的 installed_version_conflict(同 build_number 不同字节拒装)。
升到 18,保证能从 build 17 平滑升级。
2026-10-23 23:32:34 +08:00
kristinhe cc0070ae16 fix(migration): stop copying logs, drop raw, cap log retention
迁移在 30 GB 可用空间下仍然 ENOSPC,根因不在数据量,而在三件事叠在一起:
快照把运行日志当数据整份复制、raw 重建成几十万行、日志没有可收敛的
保留窗口。三处一起修。

- snapshot: 剪掉顶层 logs/ 再 os.walk。恢复路径复用同一个函数,因此
  restore 同样不会把日志搬回来。bundle 15.8 GB -> 约 4.7 GB。
- backfill: 不再读 legacy raw、不再重建三天窗口内的对话。raw 按设计丢弃,
  仍走原有 plan/execute 路径,因此新数据面里已有的 raw 依旧按既有规则
  判为窗口外并删除;只有记忆继续迁移。
- logging: 默认保留 15 天 -> 3 天,支持 MEMORY_LOG_RETENTION_DAYS;
  新增 gzip 感知的轮转清理与启动时一次性按日期清理,修掉
  getFilesToDelete() 在文件被 gzip 后整条放弃清理的问题。
- sdk runtime: 初始化期日志降为 debug(每次 init 原本 25 行 INFO),
  并加 ONE_MEMORY_TRACE_CLIENT_LEASE 开关输出 lease 释放栈,
  用于定位"一个进程 64 秒重建 447 次 client"的释放点。

测试:test_log_setup +5、test_data_plane_snapshot +1、
test_sdk_data_plane_migration 断言随 raw 不再迁移更新。
2026-10-23 23:26:43 +08:00
12 changed files with 347 additions and 65 deletions

No files matched your search

+16 -13
View File
@@ -495,8 +495,11 @@ class HyMemoryClient:
# 进程级初始化日志强制不带业务 request_id(哨兵 "-"),避免污染 App
# 请求上下文里按 request_id 的过滤。
#
# 用 debug 而非 info:全组件冷启动本就该是进程级一次性事件,
# 一旦有人在高频路径上重建 client,info 会把这条噪音放大几十万倍。
with request_id_scope():
logger.info(
logger.debug(
f"Initializing HyMemoryClient: mode={self._mode} "
f"enable_graph={self.enable_graph}"
)
@@ -523,7 +526,7 @@ class HyMemoryClient:
# 同步初始化所有组件
self._loop_thread.run(self._initialize())
logger.info("HyMemoryClient ready")
logger.debug("HyMemoryClient ready")
async def _initialize(self):
"""初始化所有核心组件"""
@@ -542,7 +545,7 @@ class HyMemoryClient:
loop.set_default_executor(
concurrent.futures.ThreadPoolExecutor(max_workers=_max_workers)
)
logger.info(f"[init] Default ThreadPoolExecutor max_workers={_max_workers}")
logger.debug(f"[init] Default ThreadPoolExecutor max_workers={_max_workers}")
# 1. EmbedService(注入发生在 ComponentFactory / pipeline 预热之前)
self._embed_service = (
@@ -557,7 +560,7 @@ class HyMemoryClient:
# 2. VectorStore(根据配置选择后端)
self._vector_store = create_vector_store(self._config)
await self._vector_store.initialize()
logger.info(
logger.debug(
f"[init] VectorStore ready: provider={self._config.vector_store.provider} "
f"collection={self._vector_store._collection_name}"
)
@@ -574,7 +577,7 @@ class HyMemoryClient:
f"GraphStore ({self._config.graph_store.provider}) init failed. "
f"Use mode='pro' instead of mode='ultra' to disable Graph, or check logs for details."
)
logger.info(
logger.debug(
f"[init] GraphStore ready: provider={self._config.graph_store.provider}"
)
@@ -594,10 +597,10 @@ class HyMemoryClient:
if self._owns_runtime:
self._cache = create_cache(self._config)
await self._cache.initialize()
logger.info(f"[init] Cache ready (backend={self._config.cache.backend})")
logger.debug(f"[init] Cache ready (backend={self._config.cache.backend})")
else:
self._cache = self._runtime.cache
logger.info("[init] Cache reused from SharedRuntime")
logger.debug("[init] Cache reused from SharedRuntime")
self._registry._shared_cache = self._cache
@@ -610,14 +613,14 @@ class HyMemoryClient:
# 6. 预热组件(确保首次 add/search 不需要等待初始化)
await self._registry.get_writer()
await self._registry.get_reader()
logger.info(f"[init] Writer & Reader ready (mode={self._mode})")
logger.debug(f"[init] Writer & Reader ready (mode={self._mode})")
await self._initialize_local_reranker()
if self._mode == "ultra":
try:
await self._registry.get_system2_writer()
logger.info("[init] System2Writer ready (ultra mode)")
logger.debug("[init] System2Writer ready (ultra mode)")
except Exception as e:
logger.warning(f"[init] System2Writer warmup failed: {e}")
@@ -626,7 +629,7 @@ class HyMemoryClient:
if self._config.history.enable:
self._history_store = HistoryStore(self._config)
await self._history_store.initialize()
logger.info("[init] HistoryStore ready")
logger.debug("[init] HistoryStore ready")
# 8. MetricsCollector
# - solo 模式:bind cache 到 metrics 单例 + 启 background flush
@@ -636,9 +639,9 @@ class HyMemoryClient:
_metrics = MetricsCollector.get()
_metrics.bind_cache(self._cache)
await _metrics.start_background_tasks()
logger.info("[init] MetricsCollector ready")
logger.debug("[init] MetricsCollector ready")
else:
logger.info("[init] MetricsCollector already bound by SharedRuntime")
logger.debug("[init] MetricsCollector already bound by SharedRuntime")
# 9. Coding memory 链路占位(lazy 初始化)
# 仅在 add(messages_with_tool) 首次命中 is_coding=True 时才真正初始化。
@@ -691,7 +694,7 @@ class HyMemoryClient:
"Local rerank is enabled but initialization failed: "
f"model={reranker.model_name!r}, error={exc}"
) from exc
logger.info(
logger.debug(
"[init] Local reranker ready: model=%s device=%s "
"load_ms=%s warmup_ms=%s",
reranker.model_name,
+17
View File
@@ -15,6 +15,15 @@ class DataPlaneSnapshotRejected(RuntimeError):
self.code = code
# Runtime logs live beside the data plane but are not data: they are written by
# a rotating handler, carry no recovery value, and can grow to gigabytes. The
# migration snapshot is a recovery artifact, so copying them both doubles the
# disk cost and makes every migration run fail on a nearly-full volume.
# Only top-level entries are excluded so a business directory that happens to be
# named "logs" deeper in the tree is still copied.
SNAPSHOT_EXCLUDED_TOP_LEVEL: frozenset[str] = frozenset({"logs"})
@dataclass(frozen=True)
class DataPlaneSnapshot:
source_root: Path
@@ -64,6 +73,14 @@ def snapshot_local_data_plane(
for root, directories, files in os.walk(source, followlinks=False):
root_path = Path(root)
relative = root_path.relative_to(source)
if relative == Path("."):
# Prune before descending: an excluded directory is never walked,
# so its files are neither checked nor copied.
directories[:] = [
name
for name in directories
if name not in SNAPSHOT_EXCLUDED_TOP_LEVEL
]
target_directory = target / relative
target_directory.mkdir(parents=True, exist_ok=True)
for name in directories:
+120 -6
View File
@@ -3,8 +3,8 @@
HY Memory - 日志系统配置
标准 Python logging + TimedRotatingFileHandler,
每天一个文件,保留 15 天。支持 request_id 注入,
可按 request_id grep 日志过滤完整请求链路。
每天一个文件,默认保留 3 天(可用 MEMORY_LOG_RETENTION_DAYS 覆盖)。
支持 request_id 注入,可按 request_id grep 日志过滤完整请求链路。
Usage:
from hy_memory.utils.log_setup import setup_logging, set_request_id
@@ -18,6 +18,7 @@ Usage:
"""
import contextvars
import datetime as _datetime
import logging
import os
import sys
@@ -25,6 +26,113 @@ from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
from typing import Optional
# ================================================================
# 日志保留策略
# ================================================================
# 默认只保留 3 天。INFO 级日志单日可涨到 2 GB 以上,15 天窗口在真实使用中
# 会累积到 10 GB 量级,而这份体积会被迁移快照整份复制一遍。
DEFAULT_LOG_RETENTION_DAYS = 3
LOG_RETENTION_ENV = "MEMORY_LOG_RETENTION_DAYS"
_ROTATION_SUFFIX_FORMAT = "%Y-%m-%d"
def _is_rotation_date(value: str) -> bool:
"""Return True when value is a rotation date suffix such as 2026-10-19."""
if len(value) != len("2026-10-19"):
return False
try:
_datetime.datetime.strptime(value, _ROTATION_SUFFIX_FORMAT)
except ValueError:
return False
return True
def resolve_retention_days(explicit: Optional[int] = None) -> int:
"""Resolve the retention window: explicit arg > env var > default."""
if explicit is not None:
try:
return max(1, int(explicit))
except (TypeError, ValueError):
pass
raw = os.getenv(LOG_RETENTION_ENV, "").strip()
if raw:
try:
return max(1, int(raw))
except ValueError:
pass
return DEFAULT_LOG_RETENTION_DAYS
class GzipAwareTimedRotatingFileHandler(TimedRotatingFileHandler):
"""TimedRotatingFileHandler that still expires files after they are gzipped.
The stdlib implementation builds its deletion list by matching the active
file name against a date-suffixed pattern. Once an operator gzips a rotated
file the suffix no longer matches, so that file drops out of the list; when
fewer files than ``backupCount`` match, ``getFilesToDelete()`` returns ``[]``
and nothing is ever deleted again. The directory then grows forever while
looking correctly configured.
"""
def getFilesToDelete(self) -> list[str]: # noqa: N802 - stdlib naming
directory, base = os.path.split(self.baseFilename)
if not os.path.isdir(directory):
return []
prefix = base + "."
candidates: list[str] = []
for name in os.listdir(directory):
if not name.startswith(prefix):
continue
suffix = name[len(prefix) :]
if suffix.endswith(".gz"):
suffix = suffix[: -len(".gz")]
if _is_rotation_date(suffix):
candidates.append(os.path.join(directory, name))
candidates.sort()
if self.backupCount <= 0 or len(candidates) < self.backupCount:
return []
return candidates[: len(candidates) - self.backupCount]
def purge_expired_logs(
log_dir: str,
retention_days: int,
*,
base_name: str = "hy_memory.log",
today: Optional[_datetime.date] = None,
) -> list[str]:
"""Delete rotated log files older than the retention window.
Runs once at startup in addition to the handler's own rotation cleanup: a
directory that already accumulated weeks of files (or gzipped files the
handler cannot match) would otherwise never converge. Age comes from the
date in the file name, not mtime, because copied files carry a new mtime.
"""
directory = Path(log_dir)
if not directory.is_dir():
return []
reference = today or _datetime.date.today()
prefix = base_name + "."
removed: list[str] = []
for path in directory.iterdir():
if not path.name.startswith(prefix) or not path.is_file():
continue
suffix = path.name[len(prefix) :]
if suffix.endswith(".gz"):
suffix = suffix[: -len(".gz")]
if not _is_rotation_date(suffix):
continue
written = _datetime.datetime.strptime(suffix, _ROTATION_SUFFIX_FORMAT).date()
if (reference - written).days > retention_days:
try:
path.unlink()
except OSError:
continue
removed.append(str(path))
return removed
# ================================================================
# request_id 上下文变量 (协程安全)
# ================================================================
@@ -184,7 +292,7 @@ def _default_log_dir() -> str:
def setup_logging(
log_dir: Optional[str] = None,
retention_days: int = 15,
retention_days: Optional[int] = None,
level: Optional[int] = None,
) -> None:
"""
@@ -198,7 +306,8 @@ def setup_logging(
Args:
log_dir: 日志目录。默认 {MEMORY_DATA_DIR}/logs/
retention_days: 日志保留天数,默认 15
retention_days: 日志保留天数。默认读 MEMORY_LOG_RETENTION_DAYS,
再缺省为 DEFAULT_LOG_RETENTION_DAYS(3 天)
level: 日志级别。默认从环境变量 MEMORY_LOG_LEVEL 读取,缺省 INFO
可多次调用,但只有首次生效。
@@ -208,6 +317,8 @@ def setup_logging(
return
_logging_configured = True
resolved_retention = resolve_retention_days(retention_days)
if level is None:
level = getattr(logging, os.getenv("MEMORY_LOG_LEVEL", "INFO").upper(), logging.INFO)
@@ -215,6 +326,9 @@ def setup_logging(
resolved_dir = log_dir or _default_log_dir()
os.makedirs(resolved_dir, exist_ok=True)
# 先按策略清一次历史文件:轮转清理只在滚动时触发,已经积压的目录不会自己收敛
purge_expired_logs(resolved_dir, resolved_retention)
# hy_memory 根 logger
root_logger = logging.getLogger("hy_memory")
root_logger.setLevel(level)
@@ -231,11 +345,11 @@ def setup_logging(
# ---- 文件 Handler ----
log_file = os.path.join(resolved_dir, "hy_memory.log")
file_handler = TimedRotatingFileHandler(
file_handler = GzipAwareTimedRotatingFileHandler(
filename=log_file,
when="midnight",
interval=1,
backupCount=retention_days,
backupCount=resolved_retention,
encoding="utf-8",
)
# 滚动后文件名: hy_memory.log.2026-03-21
+9 -2
View File
@@ -11,8 +11,9 @@ requires-python = ">=3.12"
dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.5",
# SDK 版本与仓库源码保持一致。2.0.6 尚未发布到 PyPI,构建期间由下面的
# [tool.uv.sources] 指向仓库根的本地源码;发布后删掉该 sources 段即可。
"hy-memory==2.0.6",
# Retrieval's derived vector index directly consumes these locked versions.
"chromadb==1.5.9",
"numpy==2.5.2",
@@ -40,6 +41,12 @@ dependencies = [
one-memory = "one_memory.cli:main"
one-memory-core = "one_memory.cli:main"
# 打包必须用仓库里的 SDK 源码,否则 core 会去 PyPI 取已发布的 hy-memory 包,
# 仓库 hy_memory/ 下的改动(快照排除 logs、日志保留窗口)不会进包。
# 2.0.6 正式发布后删除本段,只保留依赖声明里的版本号。
[tool.uv.sources]
hy-memory = { path = "../../../" }
[dependency-groups]
dev = [
"mypy>=2.3.1",
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import threading
from collections.abc import Callable, Coroutine
@@ -40,6 +41,8 @@ BUILTIN_RETRIEVAL_PROFILE_ID = "builtin-retrieval"
_LOCAL_ONNX_PLACEHOLDER_URL = "http://127.0.0.1/local-onnx"
DEFAULT_RELEVANCE_FLOOR = 0.35
logger = logging.getLogger("one_memory.hy_sdk_runtime")
@dataclass
class _RuntimeEntry:
@@ -120,6 +123,15 @@ class ClientLease:
if self._closed:
return
self._closed = True
if _lease_trace_enabled():
# 一个进程只应持有极少数 client lease。若这里被高频调用,
# 说明某条请求路径在请求结束时释放了共享句柄,下一次调用
# 只能整份重建 client(连带 3 套组件与线程池)。
logger.warning(
"sdk client lease released: data_dir=%s",
self.data_dir,
stack_info=True,
)
try:
self.client.close()
finally:
@@ -132,6 +144,15 @@ class ClientLease:
self.close()
def _lease_trace_enabled() -> bool:
return os.getenv("ONE_MEMORY_TRACE_CLIENT_LEASE", "").strip() not in {
"",
"0",
"false",
"False",
}
class SdkClientHandle:
"""Lazy async owner for one profile-bound public SDK client lease."""
@@ -9,7 +9,6 @@ import sqlite3
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from one_memory.database_access import main_database
@@ -28,11 +27,11 @@ from one_memory.sdk_data_plane_migration import (
)
from one_memory.sdk_migration_bundle import inspect_sdk_migration_bundle
from one_memory.sdk_migration_inventory import (
LegacyRawInventory,
SdkMigrationClient,
execute_memory_migration_plan,
execute_raw_migration_plan,
read_legacy_memory_inventory,
read_legacy_raw_inventory,
read_sdk_memory_inventory,
read_sdk_raw_inventory,
record_memory_migration_plan,
@@ -414,20 +413,13 @@ async def run_sdk_data_plane_backfill(
legacy=legacy_memories,
sdk=sdk_memories,
)
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
started = database.execute(
"SELECT created_at FROM sdk_data_plane_migrations WHERE migration_id=?",
(migration_id,),
).fetchone()
if started is None:
raise DataPlaneMigrationError("sdk_data_plane_migration_start_missing")
started_at = datetime.fromisoformat(str(started["created_at"]))
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=UTC)
legacy_raw = read_legacy_raw_inventory(
database_path,
cutoff=started_at.astimezone(UTC) - timedelta(days=3),
)
# raw 全丢:迁移只搬记忆,不再搬原始对话。
#
# 仍然走同一套 plan/execute 路径,classify_raw_inventory 的判定规则不动:
# 新数据面里已有的 raw 因为拿不到 legacy 对应项,会按原有规则被判为窗口外
# 并删除。这里只做一件事——不再从 legacy 读取、不再把三天窗口内的对话
# 重建成一份新的 raw,省掉几十万行的读写与重建成本。
legacy_raw: tuple[LegacyRawInventory, ...] = ()
sdk_raw = await read_sdk_raw_inventory(client)
raw_decisions = record_raw_migration_plan(
store,
@@ -1028,28 +1028,16 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
assert result.state == "ready"
assert result.phase == "reconciled"
assert result.counts == {"verified": 2}
assert result.counts == {"verified": 1}
assert [item["memory_id"] for item in client.imported] == ["memory-1"]
assert len(client.imported_raw) == 1
assert client.imported_raw[0]["content"] == "[user]: verbatim\n[assistant]: reply"
migrated_raw_id = client.imported_raw[0]["raw_id"]
# raw 全丢:迁移只搬记忆,不再把 legacy 的对话重建成新数据面的 raw
assert client.imported_raw == []
assert store.authority() is MemoryAuthority.LEGACY
# Simulate a process dying after both SDK side effects but before startup
# could finish. The next inventory observes them as matched rather than
# Simulate a process dying after the SDK side effect but before startup
# could finish. The next inventory observes it as matched rather than
# import_exact; that reclassification must resume the settled ledger
# instead of conflicting with its original operation key.
client.memories = (_record("memory-1", content="preference"),)
client.raw = (
HySdkRawRecord(
raw_id=migrated_raw_id,
content="[user]: verbatim\n[assistant]: reply",
user_id="local",
agent_id="all-apps",
session_id="session",
source="cursor",
custom={"input_kind": "legacy_turn"},
),
)
resumed = asyncio.run(
run_sdk_data_plane_backfill(
database_path=database,
@@ -1064,9 +1052,9 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
)
)
assert resumed.state == "ready"
assert resumed.counts == {"verified": 2}
assert resumed.counts == {"verified": 1}
assert len(client.imported) == 1
assert len(client.imported_raw) == 1
assert client.imported_raw == []
def test_offline_backfill_blocks_cutover_when_an_item_is_quarantined(store):
+47 -7
View File
@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
@@ -360,6 +360,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
@@ -367,6 +369,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
@@ -379,6 +383,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
@@ -991,8 +997,8 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.5"
source = { registry = "https://pypi.org/simple" }
version = "2.0.6"
source = { directory = "../../../" }
dependencies = [
{ name = "chromadb" },
{ name = "jieba" },
@@ -1008,10 +1014,44 @@ dependencies = [
{ name = "scikit-learn" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/56/7a279122bb56b067c390b748b8a83b6a90bb2a8f237c1d3989b6317681cd/hy_memory-2.0.5.tar.gz", hash = "sha256:bfb1e9e9815377477702327fa318331d555b8256a67b5bad8d70d6a682bd9a4f", size = 839060, upload-time = "2026-10-22T12:59:30.502Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/97/090ca18b6a13d0216d2df607cb67732ff7a8eba2d0359b9ad95283b2cbdc/hy_memory-2.0.5-py3-none-any.whl", hash = "sha256:c24649d188eaa03d3b4e4e4ec20e818da884170b5f775f3cf23ecfa6b60523d6", size = 902923, upload-time = "2026-10-22T12:59:28.018Z" },
[package.metadata]
requires-dist = [
{ name = "aiomysql", marker = "extra == 'all'" },
{ name = "aiomysql", marker = "extra == 'mysql'" },
{ name = "chromadb", specifier = ">=1.5.9" },
{ name = "chromadb", marker = "extra == 'all'", specifier = ">=1.5.9" },
{ name = "faiss-cpu", marker = "extra == 'all'" },
{ name = "faiss-cpu", marker = "extra == 'faiss'" },
{ name = "fastembed", marker = "extra == 'all'" },
{ name = "fastembed", marker = "extra == 'qdrant'" },
{ name = "httpx", marker = "extra == 'dev'" },
{ name = "jieba" },
{ name = "json-repair" },
{ name = "kuzu" },
{ name = "kuzu", marker = "extra == 'all'" },
{ name = "langdetect" },
{ name = "neo4j", marker = "extra == 'all'" },
{ name = "neo4j", marker = "extra == 'graph'" },
{ name = "numpy" },
{ name = "openai", specifier = ">=1.40,<3" },
{ name = "pydantic" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pytest-asyncio", marker = "extra == 'dev'" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "qdrant-client", marker = "extra == 'all'" },
{ name = "qdrant-client", marker = "extra == 'qdrant'" },
{ name = "rank-bm25" },
{ name = "ruff", marker = "extra == 'dev'" },
{ name = "scikit-learn" },
{ name = "tiktoken" },
{ name = "torch", marker = "extra == 'all'" },
{ name = "torch", marker = "extra == 'rerank'" },
{ name = "transformers", marker = "extra == 'all'" },
{ name = "transformers", marker = "extra == 'rerank'" },
]
provides-extras = ["qdrant", "faiss", "graph", "mysql", "rerank", "all", "dev"]
[[package]]
name = "idna"
@@ -1837,7 +1877,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "filelock", specifier = ">=3.32.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "hy-memory", specifier = "==2.0.5" },
{ name = "hy-memory", directory = "../../../" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 17,
"build_number": 19,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hy-memory"
version = "2.0.5"
version = "2.0.6"
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
readme = "README.md"
requires-python = ">=3.9"
+31
View File
@@ -39,6 +39,37 @@ def test_snapshot_copies_and_verifies_local_sdk_tree(tmp_path):
assert result.total_bytes == len(b"vector") + len(b"cache")
def test_snapshot_skips_top_level_runtime_logs(tmp_path):
"""Runtime logs sit beside the data plane but are not data.
Copying them doubled a 15.8 GB migration snapshot, most of it rotated log
files, and made migrations fail with ENOSPC on an otherwise healthy volume.
Only the top-level directory is pruned: a business directory that happens to
be called "logs" deeper in the tree must still be copied.
"""
source = tmp_path / "source"
(source / "logs").mkdir(parents=True)
(source / "logs" / "hy_memory.log").write_bytes(b"x" * 4096)
(source / "logs" / "hy_memory.log.2026-10-19").write_bytes(b"y" * 4096)
(source / "vector" / "logs").mkdir(parents=True)
(source / "vector" / "logs" / "keep.bin").write_bytes(b"keep")
(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/logs/keep.bin",
}
assert not (target / "logs").exists()
assert result.total_bytes == len(b"keep") + len(b"cache")
def test_snapshot_refuses_active_writers(tmp_path):
source = tmp_path / "source"
source.mkdir()
+69
View File
@@ -222,3 +222,72 @@ class TestSingleLineAndJsonFormat:
monkeypatch.delenv("MEMORY_LOG_FORMAT", raising=False)
fmt = log_setup._make_formatter()
assert isinstance(fmt, log_setup.SingleLineFormatter)
class TestLogRetention:
"""保留窗口必须能真正收敛,而不是只在配置里写着 3 天。"""
def test_default_retention_is_three_days(self, monkeypatch):
monkeypatch.delenv("MEMORY_LOG_RETENTION_DAYS", raising=False)
assert log_setup.resolve_retention_days() == 3
def test_env_overrides_default(self, monkeypatch):
monkeypatch.setenv("MEMORY_LOG_RETENTION_DAYS", "7")
assert log_setup.resolve_retention_days() == 7
def test_explicit_argument_wins_over_env(self, monkeypatch):
monkeypatch.setenv("MEMORY_LOG_RETENTION_DAYS", "7")
assert log_setup.resolve_retention_days(2) == 2
def test_invalid_env_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("MEMORY_LOG_RETENTION_DAYS", "not-a-number")
assert log_setup.resolve_retention_days() == 3
def test_purge_removes_expired_including_gzipped_files(self, tmp_path):
import datetime as _dt
from pathlib import Path
log_dir = tmp_path / "logs"
log_dir.mkdir()
expired = ["hy_memory.log.2026-10-01", "hy_memory.log.2026-10-19.gz"]
fresh = ["hy_memory.log.2026-10-21", "hy_memory.log.2026-10-22.gz"]
for name in expired + fresh:
(log_dir / name).write_text("x")
(log_dir / "hy_memory.log").write_text("active")
(log_dir / "unrelated.log").write_text("keep")
removed = log_setup.purge_expired_logs(
str(log_dir), 3, today=_dt.date(2026, 10, 23)
)
assert sorted(Path(p).name for p in removed) == sorted(expired)
assert {p.name for p in log_dir.iterdir()} == {
"hy_memory.log",
"unrelated.log",
*fresh,
}
def test_rotation_delete_list_includes_gzipped_files(self, tmp_path):
from pathlib import Path
log_dir = tmp_path / "logs"
log_dir.mkdir()
base = log_dir / "hy_memory.log"
base.write_text("active")
for day in ("01", "02", "03", "04"):
(log_dir / f"hy_memory.log.2026-10-{day}").write_text("x")
(log_dir / "hy_memory.log.2026-09-30.gz").write_text("x")
handler = log_setup.GzipAwareTimedRotatingFileHandler(
filename=str(base), when="midnight", backupCount=2
)
try:
to_delete = {Path(p).name for p in handler.getFilesToDelete()}
finally:
handler.close()
assert to_delete == {
"hy_memory.log.2026-09-30.gz",
"hy_memory.log.2026-10-01",
"hy_memory.log.2026-10-02",
}