Compare commits

...
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 dac2c15b3f feat(memhub): memory panel lists and searches chat, working and coding
Pin hy-memory 2.0.6 and use its scene-aware APIs in the memory panel.

- Empty query: list_memories(scene="all") mixes the three scenes newest
  first, 20 per page, with per-scene totals and the source filter.
- Search: one SDK search(scene="all", rerank=False, limit=200) per
  query; Core keeps the keyword matches in a server-side result window
  (window_id, 300s idle TTL, 32 windows, ids only) and every page is cut
  from it, so pages never repeat or skip. An expired window re-searches
  and returns page 1 with window_reset. Pages are read fresh, so edits
  and deletes show.
- Items carry scene, kind, variant, task/solution and times; the panel
  shows 对话 / 工作 / 编码 badges. Detail, edit and delete pass
  kind/variant (experience edits the solution, optionally the task);
  an ambiguous id is 409, a task on a non-experience 422.
- memhub memories list/show print scene/kind/variant.
- SDK errors are re-exported through hy_sdk_runtime to keep the SDK
  import boundary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:54:08 +08:00
feitianxiang 0b775d7d61 Merge pull request #194 from Tencent-Hunyuan/release/hy-memory-2.0.6
release: hy-memory 2.0.6
2026-10-27 15:59:35 +08:00
alvinfeiandClaude Opus 5.5 4e217f845e release: hy-memory 2.0.6
Published hy-memory==2.0.6 to PyPI: search rerank switch, size-gated
MMR and near-duplicate demotion, scene="all" search and list, per-scene
list/memory_stats, productivity update/delete, reconcile time order,
HTTP 409/400 for ambiguous or bad namespaces, and the stats UTC fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:55:00 +08:00
feitianxiang 2102a298d4 Merge pull request #193 from Tencent-Hunyuan/fix/sdk-near-dup-gate
fix(sdk): gate near-duplicate demotion with the MMR top-k threshold
2026-10-27 15:42:38 +08:00
alvinfeiandClaude Opus 5.5 e540386db1 fix(sdk): gate near-duplicate demotion with the MMR top-k threshold
near_dup_demote compares each candidate with every earlier
non-duplicate by Jaccard, quadratic when candidates differ. Its pool
grows with limit (about max(3*limit, 30) plus keyword hits), so a
limit-200 page could cost ~180k comparisons. It now runs only when the
caller's limit is below MMR_MAX_TOP_K, like MMR; large pages want every
relevant item, similar ones included. The read reports
near_dup_applied next to mmr_applied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:34:41 +08:00
feitianxiang 1374ac26b7 Merge pull request #192 from Tencent-Hunyuan/feat/sdk-search-scenes
feat(sdk): rerank switch, size-gated MMR, scene="all" search and list, productivity edit/delete
2026-10-27 15:22:41 +08:00
alvinfeiandClaude Opus 5.5 47dafbb102 feat(sdk): rerank switch, size-gated MMR, scene="all" search and list, productivity edit/delete
search:
- rerank=True (default) keeps today's behavior; rerank=False skips the
  local cross-encoder, graph rerank and the coding facts' BM25 rerank for
  that call (carried on ReadRequest, so concurrent calls are independent).
- MMR now runs only when the caller's limit is below MMR_MAX_TOP_K (20).
  It is O(k^2 * pool) Jaccard and made large result pages slow.
- scene="all" searches chat, working and coding concurrently with one
  shared embedding and fuses them by Reciprocal Rank Fusion (k=60) over
  each lane's own rank, so no lane is buried by its score scale. Items
  carry scene, kind, memory_id, content, fused score, lane score/rank,
  source and times. A failed or disabled lane is reported in "errors".

list_memories:
- scene="coding"|"working" honour include_sources/exclude_sources with
  the filtered total.
- scene="all" lists the three scenes newest first with limit/offset over
  the merged order, per-scene totals and the same unified item fields.

update/delete:
- kind/variant disambiguate like get(); an experience update replaces
  its solution (task= also replaces the task and its search vectors), a
  fact update replaces the statement; delete removes experiences and
  facts with their vectors. Chat calls are unchanged.
- The HTTP server maps an ambiguous id to 409 and a bad namespace to 400
  instead of 500, passes kind/variant through, and strips the query
  string from PUT/DELETE paths, which leaked it into the memory id.

memory_stats now reads productivity times (written as naive UTC) as UTC.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:13:12 +08:00
feitianxiang 49ad1b3086 Merge pull request #185 from Tencent-Hunyuan/fix/migration-chroma-settings
fix: list and count every memory scene, and migrate without a Chroma clash
2026-10-27 14:35:45 +08:00
50 changed files with 4456 additions and 292 deletions

No files matched your search

+54 -13
View File
@@ -4,13 +4,6 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- SDK: `list_memories(scene="chat" | "coding" | "working")` lists one kind of
memory. `coding` and `working` list that scene's whole store, newest update
first (`workspace_id` still narrows it), and `total` is now the real count
rather than the page size. The new `memory_stats()` returns the count and the
latest created/updated time for each scene and their total, without reading
any memory text. Previously coding and working memories could not be listed
or counted without a workspace, so a library of 45 memories listed 22.
- The SDK migration no longer fails its first run on a home with legacy
vectors: reattaching them left a Chroma instance open with its own settings,
and the readback that followed could not open the same store
@@ -18,12 +11,6 @@ Hy Memory and MemHub are versioned and released independently.
- MemHub "添加记忆" (add a memory) no longer waits behind unprocessed history.
Notes are claimed ahead of the event-time window that keeps history strictly
old to new; they still run one at a time and in order within their session.
- SDK reconcile keeps time order. Both reconcile prompts now state that only
`memory_at` decides which memory is newer, that an older memory must never
supersede or overwrite a newer one, and that an unknown time is not newer;
the Chinese prompt now lists `memory_at` among the new-memory fields. The
reconciler also enforces this in code: a SUPERSEDE or UPDATE aimed at a memory
with a later `memory_at` becomes an ADD, so the newer memory stays current.
- The SDK memory page now reads its list, count, source and time labels and
graph from one page response for one source filter, so rows, totals and the
map no longer disagree. Memories with no recorded source are shown and
@@ -81,6 +68,60 @@ Hy Memory and MemHub are versioned and released independently.
`hy_node_permissions` proof table. Evidence, governed memories, model
profiles, SDK stores, and source/host grants are preserved.
## Hy Memory 2.0.6 — 2026-10-27
- Published
[`hy-memory==2.0.6`](https://pypi.org/project/hy-memory/2.0.6/).
- `search(..., rerank=False)` skips every reranking stage for that one
call (the local cross-encoder, the memory-graph rerank, and the BM25 rerank of
working/coding facts), so results come back in retrieval-score order. The
flag travels on the read request, so concurrent searches do not affect each
other. The default (`True`) keeps current behavior; `/api/v1/search` accepts
it too.
- MMR diversity selection now runs only when the requested `limit` is
below 20 (`MMR_MAX_TOP_K`) and is skipped for larger limits, where its
quadratic token-overlap pass is slow and gains little. Previously it ran for
every limit. `MEMORY_HYBRID_V2_MMR_LAMBDA` still sets the weight.
Near-duplicate demotion follows the same gate: it compares each candidate
with every earlier non-duplicate, which is quadratic in a pool that grows
with `limit`, and large result pages should show similar items anyway.
- `search(scene="all")` searches chat, working and coding at the same time
and merges them with Reciprocal Rank Fusion (k=60) over each scene's own
ranking, so no scene is crowded out by the other scenes' score scales. Each
result says which scene and kind it came from. A scene that fails or is turned
off is reported in `errors` and does not fail the search. Source filters apply
to all three.
- `list_memories(scene="all")` lists chat, working and coding memories
together, newest update first, with `limit`/`offset` paging over the merged
order and `total` summing all three. `scene="coding"` and `"working"` now
honor `include_sources` / `exclude_sources`, and `total` counts only the
filtered memories.
- `update()` and `delete()` now work on working/coding experiences and
facts, not just chat memories. They take the same optional `kind` / `variant`
as `get()`, and an ID found in more than one place raises
`AmbiguousMemoryIdError` instead of guessing. Updating an experience replaces
its solution and keeps its task, unless a new `task=` is also passed; updating
a fact replaces its statement. Deleting removes the stored row and its vectors.
- `list_memories(scene="chat" | "coding" | "working")` lists one kind of
memory. `coding` and `working` list that scene's whole store, newest update
first (`workspace_id` still narrows it), and `total` is now the real count
rather than the page size. The new `memory_stats()` returns the count and the
latest created/updated time for each scene and their total, without reading
any memory text. Previously coding and working memories could not be listed
or counted without a workspace, so a library of 45 memories listed 22.
- Reconcile keeps time order. Both reconcile prompts now state that only
`memory_at` decides which memory is newer, that an older memory must never
supersede or overwrite a newer one, and that an unknown time is not newer;
the Chinese prompt now lists `memory_at` among the new-memory fields. The
reconciler also enforces this in code: a SUPERSEDE or UPDATE aimed at a memory
with a later `memory_at` becomes an ADD, so the newer memory stays current.
- The HTTP server returns 409 `ambiguous_memory_id` and 400 for a bad
`kind`/`variant` instead of 500, passes `kind`/`variant` (and `task` on PUT)
to get/update/delete, and no longer reads a PUT/DELETE query string as part
of the memory id.
- `memory_stats()` reads working/coding times, which are stored as naive UTC,
as UTC rather than local time.
## Hy Memory 2.0.5 — 2026-10-22
- Published
+886 -73
View File
File diff suppressed because it is too large. Load diff
+132 -10
View File
@@ -217,6 +217,21 @@ def _in_clause(column: str, values: Optional[List[str]]) -> Tuple[str, List[Any]
return f"{column} IN ({placeholders})", list(values)
def _source_where(
include_sources: Optional[List[str]], exclude_sources: Optional[List[str]],
) -> Tuple[str, List[Any]]:
"""Caller-source filter as SQL (``1`` when unfiltered); exclusion wins."""
clauses: List[str] = []
params: List[Any] = []
if include_sources:
clauses.append(f"source IN ({','.join('?' for _ in include_sources)})")
params.extend(include_sources)
if exclude_sources:
clauses.append(f"source NOT IN ({','.join('?' for _ in exclude_sources)})")
params.extend(exclude_sources)
return (" AND ".join(clauses) or "1"), params
def _scope_matches(
row_workspace_id: Optional[str],
row_branch: Optional[str],
@@ -603,6 +618,86 @@ class CodingMemoryStore:
logger.info(f"[coding-store] deleted experience_id={experience_id}")
return True
async def edit_experience(
self,
experience_id: str,
*,
user_id: Optional[str] = None,
task: Optional[str] = None,
solution: Optional[str] = None,
search_keys: Optional[List[str]] = None,
) -> Optional[CodingMemory]:
"""调用方手动改一条经验(client.update 用)。返回改后的经验;不存在返回 None。
与 update() 的区别:update 是写入链路的覆盖式重写,带写授权校验(同
agent/session/workspace 才能改);这里是用户对已有条目的直接编辑,只动
给定的字段,其余原样保留,因此不做授权校验。
向量行只嵌 task + search_keys(solution 不进向量),所以只改 solution 时
不重嵌、不碰向量库;改了 task / search_keys 才删旧 key 行重写。
updated_at 总是刷新。
"""
prev = await self.get_by_id(experience_id, user_id=user_id)
if prev is None:
return None
updated = deepcopy(prev)
if task is not None:
updated.task = task
if solution is not None:
updated.solution = solution
if search_keys is not None:
updated.search_keys = list(search_keys)
updated.updated_at = datetime.utcnow()
await _run_in_pool(self._upsert_sql_sync, updated)
if updated.task != prev.task or updated.search_keys != prev.search_keys:
await self._delete_vec_keys_by_count(experience_id, 1 + len(prev.search_keys))
await self._upsert_vec_keys(updated)
logger.info(f"[coding-store] edited experience_id={experience_id}")
return updated
async def edit_fact(
self,
fact_id: str,
*,
statement: str,
user_id: Optional[str] = None,
) -> Optional[WorkingFact]:
"""调用方手动改一条事实的 statement。返回改后的事实;不存在返回 None。
statement 是事实层的检索文本,所以 active 事实会重嵌并覆盖它那一行向量
(node_id 就是 fact_id,upsert 即覆盖)。superseded 事实的向量行早已撤掉,
这里不把它加回去,否则会让过期结论重新占召回名额。
"""
prev = await self.get_fact(fact_id, user_id=user_id)
if prev is None:
return None
updated = deepcopy(prev)
updated.statement = statement
await _run_in_pool(self._upsert_fact_sql_sync, updated)
if updated.status == "active":
await self._upsert_fact_vec(updated)
logger.info(f"[coding-store] edited fact_id={fact_id}")
return updated
async def delete_fact(self, fact_id: str, user_id: Optional[str] = None) -> bool:
"""物理删除一条事实(SQLite 行 + 事实向量行),返回是否真有删到。
别处留下的引用(经验的 source_fact_ids、后继事实的 fact_link)不清:
召回侧本来就按存在性过滤死链(get_many / get_facts 查不到自然掉队)。
"""
prev = await self.get_fact(fact_id, user_id=user_id)
if prev is None:
logger.info(f"[coding-store] delete fact skipped (not found): {fact_id}")
return False
if self._fact_vec is not None:
try:
await self._fact_vec.delete(fact_id)
except Exception as e:
logger.debug(f"[coding-store] delete fact vec {fact_id} ignored: {e}")
await _run_in_pool(self._delete_fact_sql_sync, fact_id, prev.user_id)
logger.info(f"[coding-store] deleted fact_id={fact_id}")
return True
# ------------------------------------------------------------
# 读取
# ------------------------------------------------------------
@@ -687,26 +782,35 @@ class CodingMemoryStore:
branch: Optional[str] = None,
limit: int = 100,
offset: int = 0,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
) -> List["CodingMemory"]:
"""按 workspace_id(+可选 branch)纯过滤列出该 user 的完整 coding memory。
作用域规则见 _scope_where(与 search 路径共用同一套)。无语义排序,按
updated_at 倒序(最近修改优先)。
updated_at 倒序(最近修改优先)。include_sources / exclude_sources 按
source 列在 SQL 里过滤(排除优先),所以 LIMIT/OFFSET 在过滤之后生效。
"""
return await _run_in_pool(
self._list_by_workspace_sync, user_id, workspace_id, branch, limit, offset
self._list_by_workspace_sync, user_id, workspace_id, branch, limit, offset,
include_sources, exclude_sources,
)
def _list_by_workspace_sync(
self, user_id: str, workspace_id: str, branch: Optional[str],
limit: int, offset: int,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
) -> List["CodingMemory"]:
scope_sql, scope_params = _scope_where(workspace_id, branch)
source_sql, source_params = _source_where(include_sources, exclude_sources)
with self._lock:
assert self._conn is not None
sql = (f"SELECT * FROM coding_memory_meta WHERE user_id = ? AND {scope_sql} "
f"ORDER BY updated_at DESC LIMIT ? OFFSET ?")
params: List[Any] = [user_id] + scope_params + [limit, offset]
f"AND {source_sql} "
f"ORDER BY updated_at DESC, experience_id ASC LIMIT ? OFFSET ?")
params: List[Any] = ([user_id] + scope_params + source_params
+ [limit, offset])
cur = self._conn.execute(sql, params)
return [_row_to_memory(r) for r in cur.fetchall()]
@@ -715,29 +819,39 @@ class CodingMemoryStore:
user_id: str,
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""经验数、最新创建/修改时间、active 事实数。纯聚合,不读正文。
作用域与 list_by_workspace 相同:workspace_id 为空表示该 user 全部。
作用域与 list_by_workspace 相同:workspace_id 为空表示该 user 全部;
来源过滤同样与 list_by_workspace 一致,count 因此恒等于分页可翻到的总数。
"""
return await _run_in_pool(self._stats_sync, user_id, workspace_id, branch)
return await _run_in_pool(
self._stats_sync, user_id, workspace_id, branch,
include_sources, exclude_sources,
)
def _stats_sync(
self, user_id: str, workspace_id: Optional[str], branch: Optional[str],
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
) -> Dict[str, Any]:
scope_sql, scope_params = _scope_where(workspace_id, branch)
source_sql, source_params = _source_where(include_sources, exclude_sources)
with self._lock:
assert self._conn is not None
row = self._conn.execute(
f"SELECT COUNT(*) AS n, MAX(created_at) AS created, "
f"MAX(updated_at) AS updated FROM coding_memory_meta "
f"WHERE user_id = ? AND {scope_sql}",
[user_id] + scope_params,
f"WHERE user_id = ? AND {scope_sql} AND {source_sql}",
[user_id] + scope_params + source_params,
).fetchone()
facts = self._conn.execute(
f"SELECT COUNT(*) AS n FROM working_fact "
f"WHERE user_id = ? AND status = 'active' AND {scope_sql}",
[user_id] + scope_params,
f"WHERE user_id = ? AND status = 'active' AND {scope_sql} "
f"AND {source_sql}",
[user_id] + scope_params + source_params,
).fetchone()
return {
"count": int(row["n"] or 0),
@@ -1124,6 +1238,14 @@ class CodingMemoryStore:
(experience_id, user_id),
)
def _delete_fact_sql_sync(self, fact_id: str, user_id: str) -> None:
with self._lock:
assert self._conn is not None
self._conn.execute(
"DELETE FROM working_fact WHERE fact_id = ? AND user_id = ?",
(fact_id, user_id),
)
def _get_by_id_sync(
self, experience_id: str, user_id: Optional[str]
) -> Optional[CodingMemory]:
@@ -160,6 +160,28 @@ def near_dup_demote(
return out
# MMR 只在调用方请求的 top-k(search 的 limit)小于这个值时自动启用。
# mmr_select 的贪心是 O(k^2 * pool) 次 Jaccard,k 大时代价陡增,而大 k 本身已
# 足够容纳多样性,收益很小。权重仍由 MEMORY_HYBRID_V2_MMR_LAMBDA 控制。
MMR_MAX_TOP_K = 20
def mmr_applies(top_k: int, lambda_: float) -> bool:
"""本次检索是否走 MMR:λ 在有效区间内,且 top_k < MMR_MAX_TOP_K。"""
return 0.0 < float(lambda_) < 0.999 and int(top_k) < MMR_MAX_TOP_K
def near_dup_applies(top_k: int) -> bool:
"""本次检索是否走近重复降权:与 MMR 同一个 top-k 门槛。
near_dup_demote 让每个候选和此前所有"非重复"候选两两算 Jaccard,候选
互不相似时是 O(pool^2);池子随 limit 放大(约 max(3*limit, 30) + 关键词
命中),limit 达到 MMR_MAX_TOP_K 时代价同样陡增。大 k 的调用方(如记忆
面板翻页)要的是全部相关项,相似项也应照常出现,降权没有收益。
"""
return int(top_k) < MMR_MAX_TOP_K
def mmr_select(
hits: Sequence[Dict[str, Any]],
limit: int,
+5
View File
@@ -401,6 +401,11 @@ class ReadRequest:
intention_limit: int = 0 # Intention 路的 topk,默认 0 关闭
# 重排开关(按次生效)。False 时跳过全部重排阶段:本地 cross-encoder
# (recall.local_rerank_enabled) 与记忆关系图重排 (_apply_graph_rerank),
# 结果按召回分排序。走请求对象而非环境变量,并发请求互不影响。
rerank: bool = True
# 时间过滤
created_after: Optional[float] = None # Unix timestamp (float),只返回 gmt_created >= 此值的记忆
+18 -5
View File
@@ -57,7 +57,9 @@ from ._retrieval.ner import (
memory_matches_time_hints,
)
from ._retrieval.diversity import (
near_dup_applies,
near_dup_demote,
mmr_applies,
mmr_select,
social_boilerplate_demote,
pool_df_demote,
@@ -1279,7 +1281,10 @@ class BaseReadPipeline(ReadPipeline):
SAME_SERIES_TIMELINE_ON = bool(
getattr(self.config.recall, "same_series_timeline_enabled", False)
)
LOCAL_RERANK_ON = bool(
# 按次的重排开关(search(rerank=False)):关掉本地 cross-encoder 与图重排,
# 结果按召回分排序。只读请求对象,不碰共享状态,并发请求互不影响。
RERANK_ON = bool(getattr(request, "rerank", True))
LOCAL_RERANK_ON = RERANK_ON and bool(
getattr(self.config.recall, "local_rerank_enabled", False)
)
LOCAL_RERANK_POOL_FACTOR = max(1, int(
@@ -1431,6 +1436,11 @@ class BaseReadPipeline(ReadPipeline):
MIN_SCORE = _resolve_normal_min_score(request.min_score)
final_limit = request.limit if request.limit > 0 else 10
# MMR 只在调用方的 top-k(final_limit,即 search 的 limit)< MMR_MAX_TOP_K
# 时启用;池子(LOCAL_RERANK 下是 final_limit 的若干倍)不参与判定。
MMR_ON = mmr_applies(final_limit, MMR_LAMBDA)
# 近重复降权同样是候选两两比较,用同一个 top-k 门槛。
NEAR_DUP_ON = near_dup_applies(final_limit)
vdb_sem_limit = max(final_limit * 3, 30)
vdb_kw_limit = max(final_limit * 5, 80)
graph_limit = max(
@@ -2462,7 +2472,7 @@ class BaseReadPipeline(ReadPipeline):
# 只把已选出的 top-N 换个顺序收益很小。
relation_paths: List[Dict[str, Any]] = []
_graph_stats: Dict[str, Any] = {}
if self._expansion_on():
if RERANK_ON and self._expansion_on():
_t_graph = datetime.now()
_pool_before = len(normal_results)
normal_results, relation_paths, _graph_stats = await self._apply_graph_rerank(
@@ -2525,7 +2535,7 @@ class BaseReadPipeline(ReadPipeline):
df_thr=POOL_DF_THR,
penalty=POOL_DF_PENALTY,
)
if NEAR_DUP_THR > 0 and NEAR_DUP_PENALTY > 0:
if NEAR_DUP_ON and NEAR_DUP_THR > 0 and NEAR_DUP_PENALTY > 0:
normal_results = near_dup_demote(
normal_results,
jaccard_thr=NEAR_DUP_THR,
@@ -2538,7 +2548,7 @@ class BaseReadPipeline(ReadPipeline):
))
if LOCAL_RERANK_ON else final_limit
)
if 0.0 < MMR_LAMBDA < 0.999 and len(normal_results) > _pre_rerank_limit:
if MMR_ON and len(normal_results) > _pre_rerank_limit:
normal_results = mmr_select(
normal_results,
_pre_rerank_limit,
@@ -2715,7 +2725,7 @@ class BaseReadPipeline(ReadPipeline):
reverse=True,
)
if (
0.0 < MMR_LAMBDA < 0.999
MMR_ON
and len(multi_seed_pool) > _pre_rerank_limit
):
multi_seed_pool = mmr_select(
@@ -3544,6 +3554,9 @@ class BaseReadPipeline(ReadPipeline):
"episode_capsule_render_mode": EPISODE_CAPSULE_RENDER_MODE,
"same_series_timeline_enabled": SAME_SERIES_TIMELINE_ON,
"local_rerank_enabled": LOCAL_RERANK_ON,
"rerank": RERANK_ON,
"mmr_applied": MMR_ON,
"near_dup_applied": NEAR_DUP_ON,
"local_rerank_model": getattr(
self.config.recall,
"local_rerank_model",
+45 -7
View File
@@ -33,9 +33,28 @@ import threading
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlparse
from .retrieval import AmbiguousMemoryIdError
logger = logging.getLogger("hy_memory.server")
def _namespace_args(values: Dict[str, Any]) -> Dict[str, Any]:
"""kind / variant from a query string (lists) or a JSON body (scalars).
They disambiguate an id that exists in more than one namespace (chat,
experience, fact; working or coding). Absent means "resolve it".
"""
args: Dict[str, Any] = {}
for key in ("kind", "variant"):
value = values.get(key)
if isinstance(value, list):
value = value[0] if value else None
if value:
args[key] = str(value)
return args
# Lazy-init global client
_client = None
_client_lock = None
@@ -148,7 +167,6 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
if path == "/api/v1/metrics":
minutes = 5
if "?" in self.path:
from urllib.parse import parse_qs, urlparse
qs = parse_qs(urlparse(self.path).query)
minutes = int(qs.get("minutes", ["5"])[0])
client = _get_client()
@@ -161,7 +179,8 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
if m:
memory_id = m.group(1)
client = _get_client()
result = client.get(memory_id)
query = parse_qs(urlparse(self.path).query)
result = client.get(memory_id, **_namespace_args(query))
if result is None:
_json_response(self, 404, {"error": "not_found", "memory_id": memory_id})
else:
@@ -170,6 +189,10 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
_json_response(self, 404, {"error": "not_found", "path": path})
except AmbiguousMemoryIdError as e:
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
except ValueError as e:
_json_response(self, 400, {"error": str(e)})
except Exception as e:
logger.error(f"[server] GET {path} error: {e}", exc_info=True)
_json_response(self, 500, {"error": str(e)})
@@ -213,7 +236,7 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
_json_response(self, 500, {"error": str(e)})
def do_PUT(self):
path = self.path.rstrip("/")
path = self.path.split("?")[0].rstrip("/")
body = _read_json_body(self)
if body is None:
_json_response(self, 400, {"error": "invalid JSON body"})
@@ -228,29 +251,43 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
_json_response(self, 400, {"error": "content is required"})
return
client = _get_client()
result = client.update(memory_id, content)
result = client.update(
memory_id,
content,
**_namespace_args(body),
**({"task": body["task"]} if body.get("task") else {}),
)
_json_response(self, 200, result)
return
_json_response(self, 404, {"error": "not_found", "path": path})
except AmbiguousMemoryIdError as e:
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
except ValueError as e:
_json_response(self, 400, {"error": str(e)})
except Exception as e:
logger.error(f"[server] PUT {path} error: {e}", exc_info=True)
_json_response(self, 500, {"error": str(e)})
def do_DELETE(self):
path = self.path.rstrip("/")
path = self.path.split("?")[0].rstrip("/")
try:
m = re.match(r"^/api/v1/memories/([^/]+)$", path)
if m:
memory_id = m.group(1)
client = _get_client()
result = client.delete(memory_id)
query = parse_qs(urlparse(self.path).query)
result = client.delete(memory_id, **_namespace_args(query))
_json_response(self, 200, result)
return
_json_response(self, 404, {"error": "not_found", "path": path})
except AmbiguousMemoryIdError as e:
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
except ValueError as e:
_json_response(self, 400, {"error": str(e)})
except Exception as e:
logger.error(f"[server] DELETE {path} error: {e}", exc_info=True)
_json_response(self, 500, {"error": str(e)})
@@ -455,7 +492,8 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
if key in body:
kwargs[key] = body[key]
for key in ("limit", "min_score", "schema_min_score", "schema_limit", "reader"):
for key in ("limit", "min_score", "schema_min_score", "schema_limit", "reader",
"rerank"):
if key in body:
kwargs[key] = body[key]
+2 -2
View File
@@ -18,7 +18,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.5`。MemHub 源码版本为 `1.0.5`
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.6`。MemHub 源码版本为 `1.0.5`
(build 14),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
@@ -117,7 +117,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.5`。SDK 配置、权限边界及 search
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.6`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+1 -1
View File
@@ -2352,7 +2352,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.5",
"versionInfo": "2.0.6",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "Apache-2.0",
+1 -1
View File
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records
`NOASSERTION` until then rather than guessing.
## hy-memory 2.0.5
## hy-memory 2.0.6
License: Apache-2.0
+9 -3
View File
@@ -152,7 +152,7 @@ export function renderMemoryPageText(document: MemoryPageDocument): string {
];
for (const memory of document.memories) {
lines.push(
`- ${memory.memory_id} ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}`,
`- ${memory.memory_id} [${memory.scene}] ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}`,
` ${memory.content}${memory.content_truncated ? ` …(共 ${memory.content_characters} 字)` : ""}`,
);
}
@@ -171,6 +171,7 @@ export function renderMemoryDetailText(document: MemoryDetailDocument): string {
}
const lines = [
`记忆 ${memory.memory_id}`,
`场景:${memory.scene} 类型:${memory.kind}${memory.variant ? `(${memory.variant})` : ""}`,
`状态:${memory.status} 层:${memory.layer} 来源:${memory.source ?? "未知"}`,
`owner:${memory.owner ?? "未知"} latest:${memory.is_latest}`,
`raw IDs:${(memory.source_raw_ids as string[]).join(",") || "无"}`,
@@ -188,6 +189,10 @@ function describeMemory(memory: SdkMemoryRecord): Record<string, unknown> {
const budgeted = budget(memory.content ?? "");
return {
memory_id: memory.memory_id,
// Pre-2.0.6 Core listed chat memories only and named no scene.
scene: memory.scene ?? "chat",
kind: memory.kind ?? "chat",
variant: memory.variant ?? null,
status: memory.status,
layer: memory.layer,
is_latest: memory.is_latest,
@@ -231,8 +236,9 @@ function sdkMemory(value: unknown): SdkMemoryRecord | null {
!strings(value.superseded_by) ||
!strings(value.tags) ||
!strings(value.source_raw_ids) ||
!text(value.user_id) ||
!text(value.agent_id)
// Working/coding memories may carry an empty user or agent id.
typeof value.user_id !== "string" ||
typeof value.agent_id !== "string"
)
return null;
return value as unknown as SdkMemoryRecord;
@@ -697,6 +697,66 @@ describe("CLI-21 memory reads", () => {
expect(report.memories[1].content.length).toBeLessThan(400);
});
it("lists working and coding memories with their scene and kind", async () => {
transport.get.mockResolvedValue({
status: 200,
data: {
total: 2,
offset: 0,
limit: 20,
totals: { chat: 1, working: 1, coding: 0 },
window_id: null,
window_reset: false,
memories: [
memory("m1", "对话记忆"),
{
...memory("e1", "修复测试\n固定随机种子"),
layer: "experience",
agent_id: "",
owner: null,
source_raw_ids: [],
scene: "working",
kind: "experience",
variant: "working",
task: "修复测试",
solution: "固定随机种子",
created_at: 1_790_000_000,
updated_at: 1_790_000_100,
},
],
},
});
const streams = captureStreams();
try {
expect(await run(["memories", "list", "--json"], {})).toBe(0);
} finally {
streams.restore();
}
const report = JSON.parse(streams.stdout());
expect(report.page).toMatchObject({ state: "ok", total: 2, returned: 2 });
expect(report.memories[0]).toMatchObject({
memory_id: "m1",
scene: "chat",
kind: "chat",
variant: null,
});
expect(report.memories[1]).toMatchObject({
memory_id: "e1",
scene: "working",
kind: "experience",
variant: "working",
layer: "experience",
});
const text = captureStreams();
try {
expect(await run(["memories", "list"], {})).toBe(0);
} finally {
text.restore();
}
expect(text.stdout()).toContain("- e1 [working] active/experience");
});
it("passes the requested filters through as query parameters", async () => {
await run(
[
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.5",
"hy-memory==2.0.6",
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
"chromadb==1.5.9",
+204 -57
View File
@@ -91,6 +91,7 @@ from one_memory.hy_runtime_config import (
set_diagnostic_log_level,
)
from one_memory.hy_sdk_runtime import (
AmbiguousMemoryIdError,
SdkClientHandle,
builtin_retrieval_snapshot,
close_all_shared_runtimes,
@@ -105,6 +106,7 @@ from one_memory.hy_sdk_types import (
HySdkRawRecord,
HySdkReadError,
HySdkUserProfile,
LibraryHit,
library_keyword_hits,
parse_sdk_raw_search,
parse_sdk_search,
@@ -158,6 +160,7 @@ from one_memory.imports.sources.registry import (
)
from one_memory.imports.storage import EvidenceStore
from one_memory.imports.supervisor import ImportSupervisor
from one_memory.library_search_window import LibrarySearchWindows, window_key
from one_memory.library_upgrade import LibraryUpgradeError, prepare_library
from one_memory.loop_watchdog import LoopWatchdog
from one_memory.memory_governance import (
@@ -402,10 +405,20 @@ class ConnectSourcesResponse(BaseModel):
request_id: str | None = None
SdkMemoryKind = Literal["chat", "experience", "fact"]
SdkMemoryVariant = Literal["working", "coding"]
class SdkMemoryUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
# Chat: the memory text. Experience: the solution. Fact: the statement.
content: str = Field(min_length=1, max_length=200_000)
# Optional namespace, as listed on the memory (`kind`, `variant`).
kind: SdkMemoryKind | None = None
variant: SdkMemoryVariant | None = None
# Experiences only: also replace the task.
task: str | None = Field(default=None, min_length=1, max_length=200_000)
class SdkUserProfileResponse(BaseModel):
@@ -2841,8 +2854,21 @@ def create_app(
source: str | None = Query(default=None, min_length=1),
query: str | None = Query(default=None, max_length=512),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
limit: int = Query(default=LIBRARY_PAGE_SIZE, ge=1, le=500),
window_id: str | None = Query(default=None, min_length=1, max_length=64),
) -> HySdkMemoryPage:
"""One page of memories across chat, working and coding.
Without `query`: `list_memories(scene="all")`, newest update first;
`total` is the SDK's count across the three scenes.
With `query`: keyword search over one SDK relevance window (see
`_library_search_page`). The first request (no `window_id`) searches
and returns a `window_id`; later pages pass it back with their offset
and are cut from the same matches, so pages never overlap or skip. A
`window_id` that expired or was searched with other filters searches
again and answers page 1 with `window_reset: true`.
"""
try:
client = await _sdk_client_handle(app).client()
search_query = (query or "").strip()
@@ -2852,59 +2878,25 @@ def create_app(
status_code=422,
detail="sdk_search_supports_active_status_only",
)
# SDK search has no count-only contract and, on the local store,
# no keyword channel. Ask for one bounded relevance window with
# no score floor, then keep the rows that contain the words, so
# a word in the library is found and a word in none of it finds
# nothing. The whole library is never listed in Core.
# A larger window made one search take long enough to freeze
# the page; 20 is the bounded window the library searches.
fetch = 20
search_raw = await asyncio.to_thread(
client.search,
search_query,
user_ids=["local"],
agent_ids=[],
include_sources=[source] if source else None,
limit=fetch,
min_score=0.0,
schema_min_score=0.0,
schema_limit=fetch,
# The library lists stored memories; proactive intentions
# are a recall-time lane the list never shows.
intention_limit=0,
scene="normal",
reader=HY_MEMORY_READER,
)
hit_ids, window = library_keyword_hits(search_raw, search_query)
rows = await _get_sdk_rows(client, list(hit_ids[offset : offset + limit]))
memories: list[HySdkMemoryRecord] = []
skipped = 0
for row in rows:
if row is None:
continue
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
skipped += 1
continue
if record.status == "active":
memories.append(record)
_log_skipped_sdk_memories(skipped)
return HySdkMemoryPage(
memories=tuple(memories),
total=len(hit_ids),
limit=limit,
return await _library_search_page(
app,
client,
query=search_query,
source=source,
statuses=list(statuses),
offset=offset,
search_window=fetch if window >= fetch else None,
limit=limit,
window_id=window_id,
)
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
scene="all",
statuses=list(statuses),
include_sources=[source] if source else None,
offset=offset,
limit=limit,
order="desc",
)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=limit, offset=offset
@@ -3072,9 +3064,18 @@ def create_app(
response_model=HySdkMemoryRecord,
dependencies=protected,
)
async def get_sdk_memory(memory_id: str) -> HySdkMemoryRecord:
async def get_sdk_memory(
memory_id: str,
kind: SdkMemoryKind | None = None,
variant: SdkMemoryVariant | None = None,
) -> HySdkMemoryRecord:
_check_memory_namespace(kind, variant)
try:
memory = await _get_sdk_memory(_sdk_client_handle(app), memory_id)
memory = await _get_sdk_memory(
_sdk_client_handle(app), memory_id, kind=kind, variant=variant
)
except AmbiguousMemoryIdError:
raise HTTPException(status_code=409, detail="memory_id_ambiguous") from None
except Exception as error:
raise _sdk_http_error(error, "hy_memory_get_failed") from None
if memory is None:
@@ -3090,13 +3091,40 @@ def create_app(
memory_id: str,
request: SdkMemoryUpdateRequest,
) -> HySdkMemoryRecord:
_check_memory_namespace(request.kind, request.variant)
if request.task is not None and request.kind in {"chat", "fact"}:
raise HTTPException(status_code=422, detail="memory_task_requires_experience")
namespace = _namespace_kwargs(request.kind, request.variant)
try:
client = await app.state.sdk_write_coordinator.client()
result = await asyncio.to_thread(client.update, memory_id, request.content)
if not isinstance(result, dict) or result.get("success") is not True:
update_kwargs: dict[str, object] = dict(namespace)
if request.task is not None:
update_kwargs["task"] = request.task
result = await asyncio.to_thread(
client.update, memory_id, request.content, **update_kwargs
)
if not isinstance(result, dict):
raise HySdkError("hy_memory_update_rejected")
raw = await asyncio.to_thread(client.get, memory_id)
if result.get("success") is not True:
if "not found" in str(result.get("error_message") or ""):
raise HTTPException(status_code=404, detail="memory_not_found")
raise HySdkError("hy_memory_update_rejected")
# The SDK names the namespace it wrote; read that one back.
if result.get("kind") in {"experience", "fact"}:
namespace = {"kind": result["kind"]}
if result.get("variant") in {"working", "coding"}:
namespace["variant"] = result["variant"]
raw = await asyncio.to_thread(client.get, memory_id, **namespace)
memory = None if raw is None else HySdkMemoryRecord.from_sdk(raw)
except HTTPException:
raise
except AmbiguousMemoryIdError:
raise HTTPException(status_code=409, detail="memory_id_ambiguous") from None
except ValueError as error:
if request.task is None:
raise _sdk_http_error(error, "hy_memory_update_failed") from None
# The SDK refuses `task` for anything but an experience.
raise HTTPException(status_code=422, detail="memory_task_requires_experience") from None
except Exception as error:
raise _sdk_http_error(error, "hy_memory_update_failed") from None
if memory is None:
@@ -3108,10 +3136,17 @@ def create_app(
status_code=204,
dependencies=protected,
)
async def delete_sdk_memory(memory_id: str) -> Response:
async def delete_sdk_memory(
memory_id: str,
kind: SdkMemoryKind | None = None,
variant: SdkMemoryVariant | None = None,
) -> Response:
_check_memory_namespace(kind, variant)
try:
client = await app.state.sdk_write_coordinator.client()
result = await asyncio.to_thread(client.delete, memory_id)
result = await asyncio.to_thread(
client.delete, memory_id, **_namespace_kwargs(kind, variant)
)
if not isinstance(result, dict):
raise HySdkError("hy_memory_delete_rejected")
error_code = result.get("error_code")
@@ -3119,6 +3154,8 @@ def create_app(
raise HySdkError(str(error_code or "hy_memory_delete_incomplete"))
if result.get("success") is not True and result.get("deleted_count") != 0:
raise HySdkError("hy_memory_delete_rejected")
except AmbiguousMemoryIdError:
raise HTTPException(status_code=409, detail="memory_id_ambiguous") from None
except Exception as error:
raise _sdk_http_error(error, "hy_memory_delete_failed") from None
return Response(status_code=204)
@@ -4438,25 +4475,135 @@ def _log_skipped_sdk_memories(count: int) -> None:
_SDK_GET_CONCURRENCY = 8
async def _get_sdk_rows(client: Any, memory_ids: list[str]) -> list[object]:
async def _get_sdk_rows(client: Any, hits: list[LibraryHit]) -> list[object]:
gate = asyncio.Semaphore(_SDK_GET_CONCURRENCY)
async def one(memory_id: str) -> object:
async def one(hit: LibraryHit) -> object:
async with gate:
return await asyncio.to_thread(client.get, memory_id)
# Name the namespace the search found the row in, so an id that
# also exists elsewhere cannot make the read ambiguous.
return await asyncio.to_thread(
client.get, hit.memory_id, **_namespace_kwargs(hit.kind, hit.variant)
)
return list(await asyncio.gather(*(one(memory_id) for memory_id in memory_ids)))
return list(await asyncio.gather(*(one(hit) for hit in hits)))
async def _get_sdk_memory(
handle: SdkClientHandle,
memory_id: str,
*,
kind: str | None = None,
variant: str | None = None,
) -> HySdkMemoryRecord | None:
client = await handle.client()
raw = await asyncio.to_thread(client.get, memory_id)
raw = await asyncio.to_thread(client.get, memory_id, **_namespace_kwargs(kind, variant))
return None if raw is None else HySdkMemoryRecord.from_sdk(raw)
def _namespace_kwargs(kind: str | None, variant: str | None) -> dict[str, str]:
"""The SDK's kind/variant arguments, only the ones the caller gave."""
namespace: dict[str, str] = {}
if kind is not None:
namespace["kind"] = kind
if variant is not None:
namespace["variant"] = variant
return namespace
def _check_memory_namespace(kind: str | None, variant: str | None) -> None:
if kind == "chat" and variant is not None:
raise HTTPException(status_code=422, detail="memory_variant_requires_productivity_kind")
# Keyword search reads one SDK relevance window this large, once per window.
LIBRARY_SEARCH_WINDOW = 200
# The memory panel's page size; the API still accepts 1..500 for the CLI.
LIBRARY_PAGE_SIZE = 20
def _library_search_windows(app: FastAPI) -> LibrarySearchWindows:
windows = getattr(app.state, "library_search_windows", None)
if windows is None:
windows = LibrarySearchWindows()
app.state.library_search_windows = windows
return cast(LibrarySearchWindows, windows)
async def _library_search_page(
app: FastAPI,
client: Any,
*,
query: str,
source: str | None,
statuses: list[str],
offset: int,
limit: int,
window_id: str | None,
) -> HySdkMemoryPage:
"""One page of a keyword search, cut from a cached result window.
SDK search has no count-only contract and, on the local store, no keyword
channel. The search asks once for a bounded relevance window across chat,
working and coding (no reranking, no score floor), keeps the rows that
contain every word, and caches those matches under a `window_id`. Every
page of that search is a slice of the same matches, read fresh from the
SDK so edits and deletes show. The whole library is never listed in Core.
"""
windows = _library_search_windows(app)
key = window_key(query, source, statuses)
window = windows.get(window_id, key) if window_id else None
window_reset = window_id is not None and window is None
if window is None:
fetch = LIBRARY_SEARCH_WINDOW
search_raw = await asyncio.to_thread(
client.search,
query,
scene="all",
rerank=False,
user_ids=["local"],
agent_ids=[],
include_sources=[source] if source else None,
limit=fetch,
min_score=0.0,
working_fact_min_score=0.0,
working_experience_min_score=0.0,
schema_min_score=0.0,
schema_limit=fetch,
# The library lists stored memories; proactive intentions are a
# recall-time lane the list never shows.
intention_limit=0,
reader=HY_MEMORY_READER,
)
hits, held = library_keyword_hits(search_raw, query)
window = windows.put(key, hits, fetch if held >= fetch else None)
if window_reset:
offset = 0
rows = await _get_sdk_rows(client, list(window.hits[offset : offset + limit]))
memories: list[HySdkMemoryRecord] = []
skipped = 0
for row in rows:
if row is None:
continue
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
skipped += 1
continue
if record.status == "active":
memories.append(record)
_log_skipped_sdk_memories(skipped)
return HySdkMemoryPage(
memories=tuple(memories),
total=len(window.hits),
limit=limit,
offset=offset,
search_window=window.search_window,
window_id=window.window_id,
window_reset=window_reset,
)
async def _search_sdk_memories(
handle: SdkClientHandle,
query: str,
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.5"
HY_MEMORY_VERSION = "2.0.6"
HY_MEMORY_NAMESPACE = "governed-v2"
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
@@ -17,6 +17,9 @@ from pathlib import Path
from typing import Any, ClassVar
from hy_memory import ( # type: ignore[import-untyped]
AmbiguousMemoryIdError as AmbiguousMemoryIdError, # re-export for api.py
)
from hy_memory import (
HyMemoryClient,
MemoryConfig,
SharedRuntime,
+243 -21
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Literal, NamedTuple, cast
from pydantic import BaseModel, ConfigDict, Field
@@ -29,7 +31,21 @@ class HySdkReadError(HySdkError):
"""A public SDK read operation failed or returned an invalid shape."""
MemoryScene = Literal["chat", "working", "coding"]
MemoryKindName = Literal["chat", "experience", "fact"]
MemoryVariantName = Literal["working", "coding"]
class HySdkMemoryRecord(BaseModel):
"""One memory of any scene, in the unified shape the library lists.
Chat memories keep every field they always had. Working/coding memories
(experiences and facts) carry ``scene``/``kind``/``variant`` so a client
can open, edit and delete them again without guessing the namespace; an
experience also keeps ``task`` and ``solution`` apart, and ``content`` is
the readable ``task\nsolution`` text the SDK lists.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
memory_id: str
@@ -48,17 +64,36 @@ class HySdkMemoryRecord(BaseModel):
source: str | None = None
memory_at: int | None = None
gmt_created: int | None = None
scene: MemoryScene = "chat"
kind: MemoryKindName = "chat"
# The productivity namespace ("working" | "coding"); None for chat. Pass it
# back with `kind` on get/update/delete.
variant: MemoryVariantName | None = None
task: str | None = None
solution: str | None = None
# Epoch seconds. `updated_at` falls back to `created_at`.
created_at: int | None = None
updated_at: int | None = None
@classmethod
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
if not isinstance(raw, Mapping):
raise HySdkReadError("hy_memory_record_shape_unknown")
if raw.get("kind") in {"experience", "fact"}:
return cls._from_productivity(raw)
if any(
not isinstance(raw.get(key), str) or not str(raw[key]).strip()
for key in ("memory_id", "content")
):
raise HySdkReadError("hy_memory_record_shape_unknown")
try:
gmt_created = (
raw.get("gmt_created") if isinstance(raw.get("gmt_created"), int) else None
)
created_at = _epoch(raw.get("created_at"))
if created_at is None:
created_at = gmt_created
updated_at = _epoch(raw.get("updated_at"))
return cls(
memory_id=str(raw["memory_id"]),
content=str(raw["content"]),
@@ -77,9 +112,69 @@ class HySdkMemoryRecord(BaseModel):
),
source=str(raw["source"]) if isinstance(raw.get("source"), str) else None,
memory_at=raw.get("memory_at") if isinstance(raw.get("memory_at"), int) else None,
gmt_created=(
raw.get("gmt_created") if isinstance(raw.get("gmt_created"), int) else None
gmt_created=gmt_created,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@classmethod
def _from_productivity(cls, raw: Mapping[str, object]) -> HySdkMemoryRecord:
"""An experience or fact from list/search(scene="all") or get().
get() names the namespace in ``variant`` (its ``scene`` is the legacy
"productivity"); list/search name it in ``scene``.
"""
kind = str(raw["kind"])
variant = next(
(
value
for value in (raw.get("variant"), raw.get("scene"))
if value in {"working", "coding"}
),
None,
)
id_key = "experience_id" if kind == "experience" else "fact_id"
memory_id = raw.get("memory_id") or raw.get(id_key)
if variant is None or not isinstance(memory_id, str) or not memory_id.strip():
raise HySdkReadError("hy_memory_record_shape_unknown")
task: str | None = None
solution: str | None = None
if kind == "experience":
task = str(raw.get("task") or "").strip()
solution = str(raw.get("solution") or "").strip()
content = "\n".join(part for part in (task, solution) if part)
else:
statement = raw.get("statement")
content = str(statement if isinstance(statement, str) else raw.get("content") or "")
if not content.strip():
raise HySdkReadError("hy_memory_record_shape_unknown")
status = str(raw.get("status") or "active")
created_at = _epoch(raw.get("created_at"))
updated_at = _epoch(raw.get("updated_at"))
try:
return cls(
memory_id=memory_id,
content=content,
layer=kind,
status=status,
is_latest=status == "active",
tags=_string_tuple(raw.get("tags")),
user_id=str(raw.get("user_id") or ""),
agent_id=str(raw.get("agent_id") or ""),
session_id=(
str(raw["session_id"]) if isinstance(raw.get("session_id"), str) else None
),
source=str(raw["source"]) if isinstance(raw.get("source"), str) else None,
gmt_created=created_at,
scene=cast(MemoryScene, variant),
kind=cast(MemoryKindName, kind),
variant=cast(MemoryVariantName, variant),
task=task,
solution=solution,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@@ -95,6 +190,15 @@ class HySdkMemoryPage(BaseModel):
# Set on a search whose relevance window was full: matches were looked for
# only among this many most relevant memories, so `total` may undercount.
search_window: int | None = None
# Keyword search only: the server-side result window this page was cut
# from. Pass it back with the next offset to page the same results.
window_id: str | None = None
# True when the window_id the client sent had expired or did not match the
# query: the search ran again and this is page 1 of the new window.
window_reset: bool = False
# Empty-query listing only: how many memories each scene holds under the
# same filters (`total` is their sum).
totals: dict[str, int] | None = None
@classmethod
def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkMemoryPage:
@@ -123,6 +227,8 @@ class HySdkMemoryPage(BaseModel):
) -> tuple[HySdkMemoryPage, int]:
if not isinstance(raw, Mapping):
raise HySdkReadError("hy_memory_list_shape_unknown")
if raw.get("scene") == "all":
return cls._parse_all_scenes(raw, limit=limit, offset=offset, skip_invalid=skip_invalid)
rows: list[object] = []
total = 0
for bucket_name, rows_name in (("vdb", "memories"), ("graph", "nodes")):
@@ -163,6 +269,62 @@ class HySdkMemoryPage(BaseModel):
)
return page, skipped
@classmethod
def _parse_all_scenes(
cls,
raw: Mapping[str, object],
*,
limit: int,
offset: int,
skip_invalid: bool,
) -> tuple[HySdkMemoryPage, int]:
"""``list_memories(scene="all")``: chat, working and coding, newest first."""
rows = raw.get("memories")
total = raw.get("total")
if (
not isinstance(rows, list)
or not isinstance(total, int)
or isinstance(total, bool)
or total < 0
):
raise HySdkReadError("hy_memory_list_shape_unknown")
totals_raw = raw.get("totals")
totals = (
{
str(scene): count
for scene, count in totals_raw.items()
if isinstance(count, int) and not isinstance(count, bool) and count >= 0
}
if isinstance(totals_raw, Mapping)
else None
)
memories: list[HySdkMemoryRecord] = []
seen: set[tuple[str, str, str | None]] = set()
skipped = 0
for row in rows:
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
if not skip_invalid:
raise
skipped += 1
continue
key = (record.memory_id, record.kind, record.variant)
if key in seen:
continue
seen.add(key)
memories.append(record)
return (
cls(
memories=tuple(memories),
total=total,
limit=limit,
offset=offset,
totals=totals,
),
skipped,
)
class HySdkRawRecord(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -327,38 +489,79 @@ def parse_sdk_search(raw: object, *, error_code: str) -> tuple[SemanticMemoryHit
return tuple(found)
def library_keyword_hits(raw: object, query: str) -> tuple[tuple[str, ...], int]:
"""Ids in an SDK search window whose text contains every query term.
class LibraryHit(NamedTuple):
"""One keyword match in a library search window, with its namespace."""
memory_id: str
kind: MemoryKindName
variant: MemoryVariantName | None
def library_keyword_hits(raw: object, query: str) -> tuple[tuple[LibraryHit, ...], int]:
"""Memories in an SDK search window whose text contains every query term.
The local SDK store has no keyword channel (Chroma's keyword search is
empty), so search is pure vector similarity: a word in the library can
score below the floor, and a word in none of it still scores above. The
library search asks the SDK for its most relevant window and keeps only the
rows that actually contain the words, most relevant first. Returns those ids
and how many distinct rows the window held.
rows that actually contain the words, most relevant first. Returns those
hits and how many distinct rows the window held.
A ``search(scene="all")`` window mixes chat memories with working/coding
experiences and facts; their text is the unified ``content`` (an
experience's task and solution, a fact's statement). The fused order is
kept as the SDK ranked it.
"""
rows = _reply_rows(raw, "hy_memory_search_shape_unknown")
terms = query.casefold().split()
seen: set[str] = set()
matched: list[str] = []
for item in sorted(rows, key=_score, reverse=True):
memory_id = item.get("memory_id") or item.get("id")
if not isinstance(memory_id, str) or not memory_id or memory_id in seen:
if isinstance(raw, Mapping) and raw.get("scene") == "all":
rows = raw.get("memories")
if not isinstance(rows, list) or any(not isinstance(row, Mapping) for row in rows):
raise HySdkReadError("hy_memory_search_shape_unknown")
ordered: list[Mapping[str, object]] = list(rows)
else:
ordered = sorted(
_reply_rows(raw, "hy_memory_search_shape_unknown"), key=_score, reverse=True
)
seen: set[LibraryHit] = set()
matched: list[LibraryHit] = []
for item in ordered:
hit = _library_hit(item)
if hit is None or hit in seen:
continue
seen.add(memory_id)
content = item.get("content")
seen.add(hit)
tags = item.get("tags")
text = " ".join(
[
content if isinstance(content, str) else "",
*(tag for tag in (tags if isinstance(tags, list) else ()) if isinstance(tag, str)),
]
).casefold()
parts = [item.get(key) for key in ("content", "task", "solution", "statement")] + list(
tags if isinstance(tags, list) else ()
)
text = " ".join(part for part in parts if isinstance(part, str)).casefold()
if all(term in text for term in terms):
matched.append(memory_id)
matched.append(hit)
return tuple(matched), len(seen)
def _library_hit(item: Mapping[str, object]) -> LibraryHit | None:
kind = item.get("kind")
if kind in {"experience", "fact"}:
variant = item.get("variant") if item.get("variant") in {"working", "coding"} else None
variant = variant or (
item.get("scene") if item.get("scene") in {"working", "coding"} else None
)
memory_id = item.get("memory_id") or item.get(
"experience_id" if kind == "experience" else "fact_id"
)
if variant is None or not isinstance(memory_id, str) or not memory_id:
return None
return LibraryHit(
memory_id,
"experience" if kind == "experience" else "fact",
"coding" if variant == "coding" else "working",
)
memory_id = item.get("memory_id") or item.get("id")
if not isinstance(memory_id, str) or not memory_id:
return None
return LibraryHit(memory_id, "chat", None)
def parse_sdk_raw_search(raw: object) -> tuple[HySdkRawHit, ...]:
return tuple(
HySdkRawHit.from_sdk_search(row)
@@ -366,6 +569,25 @@ def parse_sdk_raw_search(raw: object) -> tuple[HySdkRawHit, ...]:
)
def _epoch(value: object) -> int | None:
"""Epoch seconds from an int or an ISO-8601 string (naive means UTC)."""
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str) and value.strip():
try:
parsed = datetime.fromisoformat(value.strip())
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return int(parsed.timestamp())
return None
def _string_tuple(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
@@ -0,0 +1,110 @@
"""Short-lived result windows for the memory library's keyword search.
A library search asks the SDK once for a bounded relevance window and keeps
the keyword matches. Paging must cut every page from that same list: running
the search again per page can rank differently and duplicate or skip memories
between pages. The matches are kept here under an opaque ``window_id`` that
the client passes back with the next offset.
A window is bound to the filters it was searched with (query, source,
statuses). It expires after ``ttl_seconds`` without being read, and the store
keeps at most ``max_windows`` windows, dropping the least recently read first.
Only memory identities are kept, never content: each page is read fresh from
the SDK, so an edit or a delete shows on the next page read.
"""
from __future__ import annotations
import secrets
import threading
import time
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass, field
from one_memory.hy_sdk_types import LibraryHit
DEFAULT_WINDOW_TTL_SECONDS = 300.0
DEFAULT_MAX_WINDOWS = 32
WindowKey = tuple[str, str | None, tuple[str, ...]]
def window_key(query: str, source: str | None, statuses: list[str]) -> WindowKey:
return (query, source, tuple(sorted(set(statuses))))
@dataclass(frozen=True)
class SearchWindow:
window_id: str
key: WindowKey
hits: tuple[LibraryHit, ...]
# Set when the SDK window was full, so matches outside it were not looked
# for and `total` may undercount.
search_window: int | None
@dataclass
class _Entry:
window: SearchWindow
last_read: float = field(default=0.0)
class LibrarySearchWindows:
def __init__(
self,
*,
ttl_seconds: float = DEFAULT_WINDOW_TTL_SECONDS,
max_windows: int = DEFAULT_MAX_WINDOWS,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._ttl = ttl_seconds
self._max = max(1, max_windows)
self._clock = clock
self._entries: OrderedDict[str, _Entry] = OrderedDict()
self._lock = threading.Lock()
def get(self, window_id: str, key: WindowKey) -> SearchWindow | None:
"""The window, if it is still live and was searched with `key`."""
with self._lock:
now = self._clock()
self._expire(now)
entry = self._entries.get(window_id)
if entry is None or entry.window.key != key:
return None
entry.last_read = now
self._entries.move_to_end(window_id)
return entry.window
def put(
self,
key: WindowKey,
hits: tuple[LibraryHit, ...],
search_window: int | None,
) -> SearchWindow:
window = SearchWindow(
window_id=secrets.token_urlsafe(12),
key=key,
hits=hits,
search_window=search_window,
)
with self._lock:
now = self._clock()
self._expire(now)
self._entries[window.window_id] = _Entry(window=window, last_read=now)
while len(self._entries) > self._max:
self._entries.popitem(last=False)
return window
def __len__(self) -> int:
with self._lock:
return len(self._entries)
def _expire(self, now: float) -> None:
stale = [
window_id
for window_id, entry in self._entries.items()
if now - entry.last_read >= self._ttl
]
for window_id in stale:
del self._entries[window_id]
+3 -1
View File
@@ -129,6 +129,7 @@
| `test_intake_dedupe.py` | `contract` | A4-07: hook and scan share one identity, so order cannot duplicate. |
| `test_internal_preset.py` | `contract` | The optional model preset a configured build carries. |
| `test_jsonl_snapshot_tail.py` | `contract` | Bounded EOF handling on synthetic files only, never original host history. |
| `test_library_search_window.py` | `unit` | The library search window store and the unified record/keyword parsing. |
| `test_live_capture.py` | `integration` | Visible hook text lands in Evidence before the transcript file is flushed. |
| `test_live_sync.py` | `contract` | 覆盖 live sync preempts a reader batch before it emits work、live sync cuts in front of a running history import、live sync does not resume a history import the user paused 等 24 个场景。 |
| `test_live_sync_file_incremental.py` | `contract` | 覆盖 unchanged file metadata avoids a job and content read、changed file content is read once by the import job、workbuddy first live poll baselines history then imports only append 等 6 个场景。 |
@@ -156,6 +157,7 @@
| `test_memory_content_import.py` | `integration` | 覆盖 existing memory preview keeps unknown author and exact original、invalid existing memory records never leave a partial upload、memory content requires explicit mode and rejects vendor guessing 等 5 个场景。 |
| `test_memory_evidence_freshness.py` | `contract` | 新鲜度拆除后:读路径恒为 current;证据修订 / 畸形 proof / segment ref 不再藏记忆或 503。 |
| `test_memory_freshness_integration_review.py` | `integration` | Independent synthetic read-path review; no lifespan, model or user database. |
| `test_memory_library_all_scenes.py` | `integration` | The v2 memory library across chat, working and coding memories. |
| `test_memory_notes.py` | `integration` | 覆盖 empty library can save a note without a matter or configured model、note requires authentication explicit scope and nonempty content、saved note evidence returns raw and consent reference from same record 等 6 个场景。 |
| `test_memory_qa.py` | `contract` | 覆盖 search terms keep meaningful single chinese character、streamed answer emits recall then incremental grounded text、governance change during generation stops the old answer 等 14 个场景。 |
| `test_memory_qa_api.py` | `integration` | 覆盖 ask stream endpoint emits recall deltas and final、ask stream endpoint requires the local token 场景。 |
@@ -304,4 +306,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 299 个测试文件。
共 301 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 299
EXPECTED_TEST_FILES_SHA256 = "7b6afc4f9da9d5ba2829639f38d964f89c78bb71d9cadae3dba9b4aad91e3a2c"
EXPECTED_TEST_FILE_COUNT = 301
EXPECTED_TEST_FILES_SHA256 = "1e5f3a85f124a26ed67861e07fba7c670512a82139db64c1b7ba767ca37ed563"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -31,6 +31,7 @@ UNIT_FILES = frozenset(
"test_graph_width_guard.py",
"test_host_capabilities.py",
"test_input_sanitization.py",
"test_library_search_window.py",
"test_memory_server_config_state.py",
"test_memory_server_contracts.py",
"test_memory_server_job_state.py",
@@ -77,6 +78,7 @@ INTEGRATION_FILES = frozenset(
"test_measure_c1_loop_blocking_contract.py",
"test_memory_content_import.py",
"test_memory_freshness_integration_review.py",
"test_memory_library_all_scenes.py",
"test_memory_notes.py",
"test_memory_qa_api.py",
"test_memory_server_durable_queue.py",
@@ -0,0 +1,118 @@
"""The library search window store and the unified record/keyword parsing."""
from one_memory.hy_sdk_types import HySdkMemoryPage, HySdkMemoryRecord, library_keyword_hits
from one_memory.library_search_window import LibrarySearchWindows, window_key
def test_window_is_bound_to_its_filters_and_expires_when_idle():
now = [0.0]
windows = LibrarySearchWindows(ttl_seconds=10, clock=lambda: now[0])
key = window_key("deploy", None, ["active"])
window = windows.put(key, (), None)
assert windows.get(window.window_id, key) is window
assert windows.get(window.window_id, window_key("deploy", "cursor", ["active"])) is None
now[0] = 9.0
assert windows.get(window.window_id, key) is window # reading keeps it alive
now[0] = 18.0
assert windows.get(window.window_id, key) is window
now[0] = 28.5
assert windows.get(window.window_id, key) is None
assert len(windows) == 0
def test_window_store_is_bounded_least_recently_read_first():
windows = LibrarySearchWindows(max_windows=2)
key = window_key("q", None, ["active"])
first = windows.put(key, (), None)
second = windows.put(key, (), None)
assert windows.get(first.window_id, key) is first
third = windows.put(key, (), None)
assert len(windows) == 2
assert windows.get(second.window_id, key) is None
assert windows.get(first.window_id, key) is first
assert windows.get(third.window_id, key) is third
def test_keyword_hits_read_unified_items_in_fused_order():
raw = {
"scene": "all",
"memories": [
{"memory_id": "c1", "scene": "chat", "kind": "chat", "content": "Deploy on Fridays"},
{
"memory_id": "e1",
"experience_id": "e1",
"scene": "working",
"kind": "experience",
"content": "Deploy service\nUse blue/green",
},
{
"memory_id": "f1",
"fact_id": "f1",
"scene": "coding",
"kind": "fact",
"content": "Nothing relevant",
"statement": "Nothing relevant",
},
{"memory_id": "c1", "scene": "chat", "kind": "chat", "content": "Deploy on Fridays"},
],
}
hits, held = library_keyword_hits(raw, "deploy")
assert held == 3
assert [(hit.memory_id, hit.kind, hit.variant) for hit in hits] == [
("c1", "chat", None),
("e1", "experience", "working"),
]
def test_productivity_get_becomes_a_unified_record():
record = HySdkMemoryRecord.from_sdk(
{
"memory_id": "e1",
"kind": "experience",
"variant": "coding",
"scene": "productivity",
"content": "solution only",
"task": "the task",
"solution": "solution only",
"user_id": "local",
"agent_id": "codebuddy",
"source": "codebuddy",
"created_at": "2026-10-01T00:00:00",
"updated_at": None,
}
)
assert (record.scene, record.kind, record.variant) == ("coding", "experience", "coding")
assert record.content == "the task\nsolution only"
assert record.status == "active"
assert record.created_at == record.updated_at == 1_790_812_800
def test_all_scene_list_page_keeps_totals_and_skips_invalid_rows():
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
{
"scene": "all",
"memories": [
{"memory_id": "c1", "content": "chat", "scene": "chat", "kind": "chat"},
{"memory_id": "bad", "content": "", "scene": "chat", "kind": "chat"},
{
"memory_id": "f1",
"kind": "fact",
"scene": "working",
"content": "fact",
"status": "active",
},
],
"total": 3,
"totals": {"chat": 2, "working": 1, "coding": 0},
},
limit=20,
offset=0,
)
assert skipped == 1
assert page.total == 3
assert page.totals == {"chat": 2, "working": 1, "coding": 0}
assert [(m.memory_id, m.scene, m.kind) for m in page.memories] == [
("c1", "chat", "chat"),
("f1", "working", "fact"),
]
@@ -0,0 +1,551 @@
"""The v2 memory library across chat, working and coding memories.
Keyword search pages are cut from one cached SDK result window (window_id),
the empty-query list mixes the three scenes newest first, and detail, edit
and delete pass the memory's kind/variant through to the SDK.
"""
import asyncio
import pytest
from httpx import ASGITransport, AsyncClient
from hy_memory import AmbiguousMemoryIdError
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.library_search_window import LibrarySearchWindows
from one_memory.secrets import InMemorySecretStore
def application(tmp_path):
return create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("library-test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
secret_store=InMemorySecretStore(),
)
def client_for(app):
return AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer library-test"},
)
def chat_row(memory_id, content, *, source="cursor"):
return {
"memory_id": memory_id,
"content": content,
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": source,
"tags": [],
"gmt_created": 1_790_000_000,
}
def experience_row(memory_id, task, solution, *, variant="working", source="codebuddy"):
return {
"experience_id": memory_id,
"user_id": "local",
"agent_id": "codebuddy",
"task": task,
"solution": solution,
"source": source,
"created_at": "2026-10-01T08:00:00",
"updated_at": "2026-10-02T08:00:00",
"_variant": variant,
}
def fact_row(memory_id, statement, *, variant="coding", source="codebuddy"):
return {
"fact_id": memory_id,
"user_id": "local",
"agent_id": "codebuddy",
"statement": statement,
"status": "active",
"source": source,
"created_at": "2026-10-03T08:00:00",
"_variant": variant,
}
class FakeSdk:
"""Public-API shaped client holding chat, experience and fact memories."""
def __init__(self):
self.chat = {}
self.productivity = {}
self.search_calls = []
self.list_calls = []
self.get_calls = []
self.update_calls = []
self.delete_calls = []
self.reverse_next_search = False
def add_chat(self, row):
self.chat[row["memory_id"]] = row
def add_productivity(self, kind, row):
memory_id = row["experience_id"] if kind == "experience" else row["fact_id"]
self.productivity[(kind, row["_variant"], memory_id)] = row
def _unified(self, kind, variant, row):
if kind == "experience":
content = f"{row['task']}\n{row['solution']}"
memory_id = row["experience_id"]
else:
content = row["statement"]
memory_id = row["fact_id"]
return {
**{key: value for key, value in row.items() if not key.startswith("_")},
"scene": variant,
"kind": kind,
"memory_id": memory_id,
"content": content,
"created_at": 1_790_000_000,
"updated_at": 1_790_000_100,
}
def _matches(self, memory_id, kind, variant):
found = []
if kind in (None, "chat") and variant is None and memory_id in self.chat:
found.append(("chat", None, self.chat[memory_id]))
for (row_kind, row_variant, row_id), row in self.productivity.items():
if row_id != memory_id:
continue
if kind not in (None, row_kind) or variant not in (None, row_variant):
continue
found.append((row_kind, row_variant, row))
if len(found) > 1:
raise AmbiguousMemoryIdError(
memory_id,
[f"{v}:{k}" if v else k for k, v, _ in found],
)
return found[0] if found else None
def search(self, query, **kwargs):
self.search_calls.append((query, kwargs))
include = kwargs.get("include_sources") or []
items = [
{**row, "scene": "chat", "kind": "chat"}
for row in self.chat.values()
if not include or row["source"] in include
]
items += [
self._unified(kind, variant, row)
for (kind, variant, _id), row in self.productivity.items()
if not include or row["source"] in include
]
if self.reverse_next_search:
items.reverse()
items = items[: kwargs["limit"]]
for rank, item in enumerate(items, start=1):
item["score"] = round(1 / (60 + rank), 6)
return {
"request_id": "search",
"scene": "all",
"memories": items,
"lanes": {"chat": 0, "working": 0, "coding": 0},
"errors": {},
"elapsed_ms": 1.0,
}
def list_memories(self, **kwargs):
self.list_calls.append(kwargs)
assert kwargs["scene"] == "all"
include = kwargs.get("include_sources") or []
chat = [
{**row, "scene": "chat", "kind": "chat", "created_at": 10, "updated_at": 10}
for row in self.chat.values()
if not include or row["source"] in include
]
productivity = [
{**self._unified(kind, variant, row), "status": "active"}
for (kind, variant, _id), row in self.productivity.items()
if kind == "experience" and (not include or row["source"] in include)
]
rows = productivity + chat
page = rows[kwargs["offset"] : kwargs["offset"] + kwargs["limit"]]
return {
"scene": "all",
"memories": page,
"total": len(rows),
"totals": {
"chat": len(chat),
"working": sum(1 for row in productivity if row["scene"] == "working"),
"coding": sum(1 for row in productivity if row["scene"] == "coding"),
},
"errors": {},
"limit": kwargs["limit"],
"offset": kwargs["offset"],
"request_id": "list",
"elapsed_ms": 1.0,
}
def get(self, memory_id, kind=None, variant=None):
self.get_calls.append((memory_id, kind, variant))
match = self._matches(memory_id, kind, variant)
if match is None:
return None
row_kind, row_variant, row = match
if row_kind == "chat":
return {**row, "kind": "chat", "variant": None}
payload = {key: value for key, value in row.items() if not key.startswith("_")}
return {
"memory_id": memory_id,
"kind": row_kind,
"variant": row_variant,
"scene": "productivity",
"content": row["solution"] if row_kind == "experience" else row["statement"],
"layer": row_kind,
**payload,
}
def update(self, memory_id, content, kind=None, variant=None, task=None):
self.update_calls.append((memory_id, content, kind, variant, task))
match = self._matches(memory_id, kind, variant)
if match is None:
return {"success": False, "error_message": f"memory_id '{memory_id}' not found"}
row_kind, row_variant, row = match
if row_kind == "chat":
row["content"] = content
return {"success": True, "memory_id": memory_id}
if row_kind == "experience":
row["solution"] = content
if task is not None:
row["task"] = task
else:
row["statement"] = content
return {"success": True, "memory_id": memory_id, "kind": row_kind, "variant": row_variant}
def delete(self, memory_id, kind=None, variant=None):
self.delete_calls.append((memory_id, kind, variant))
match = self._matches(memory_id, kind, variant)
if match is None:
return {"success": False, "deleted_count": 0}
row_kind, row_variant, row = match
if row_kind == "chat":
del self.chat[memory_id]
else:
del self.productivity[(row_kind, row_variant, memory_id)]
return {"success": True, "deleted_count": 1}
def run_with_sdk(tmp_path, monkeypatch, scenario):
async def main():
app = application(tmp_path)
sdk = FakeSdk()
class Handle:
async def client(self):
return sdk
async def close(self):
return None
async def write_client():
return sdk
async with app.router.lifespan_context(app):
app.state.sdk_client_handle = Handle()
monkeypatch.setattr(app.state.sdk_write_coordinator, "client", write_client)
async with client_for(app) as client:
await scenario(app, sdk, client)
asyncio.run(main())
@pytest.mark.fresh_sdk
def test_search_pages_come_from_one_cached_window_without_duplicates(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
for index in range(30):
sdk.add_chat(chat_row(f"chat-{index:02d}", f"deploy note {index}"))
for index in range(15):
sdk.add_productivity(
"experience",
experience_row(f"exp-{index:02d}", f"deploy task {index}", "run make"),
)
sdk.add_chat(chat_row("other", "unrelated words"))
first = await client.get("/api/v2/memory-library", params={"query": "deploy"})
assert first.status_code == 200
body = first.json()
assert body["total"] == 45
assert body["limit"] == 20
assert body["offset"] == 0
assert body["window_reset"] is False
assert body["search_window"] is None
window_id = body["window_id"]
assert window_id
assert len(sdk.search_calls) == 1
query, options = sdk.search_calls[0]
assert query == "deploy"
assert options["scene"] == "all"
assert options["rerank"] is False
assert options["limit"] == 200
assert options["min_score"] == 0.0
# A second search would now rank differently; pages must not care.
sdk.reverse_next_search = True
seen = [item["memory_id"] for item in body["memories"]]
for offset in (20, 40):
page = await client.get(
"/api/v2/memory-library",
params={"query": "deploy", "offset": offset, "window_id": window_id},
)
assert page.status_code == 200
data = page.json()
assert data["window_id"] == window_id
assert data["window_reset"] is False
assert data["offset"] == offset
assert data["total"] == 45
seen += [item["memory_id"] for item in data["memories"]]
assert len(sdk.search_calls) == 1
assert len(seen) == 45
assert len(set(seen)) == 45
scenes = {item["memory_id"]: item for item in body["memories"]}
assert {item["scene"] for item in scenes.values()} <= {"chat", "working"}
# Each productivity row is read back in the namespace it was found in.
productivity_gets = [call for call in sdk.get_calls if call[0].startswith("exp-")]
assert productivity_gets
assert all(call[1:] == ("experience", "working") for call in productivity_gets)
chat_gets = [call for call in sdk.get_calls if call[0].startswith("chat-")]
assert all(call[1:] == ("chat", None) for call in chat_gets)
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_expired_or_mismatched_window_searches_again_and_restarts(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
now = [1000.0]
app.state.library_search_windows = LibrarySearchWindows(
ttl_seconds=60, clock=lambda: now[0]
)
for index in range(25):
sdk.add_chat(chat_row(f"chat-{index:02d}", f"deploy note {index}"))
first = (await client.get("/api/v2/memory-library", params={"query": "deploy"})).json()
window_id = first["window_id"]
now[0] += 61
expired = await client.get(
"/api/v2/memory-library",
params={"query": "deploy", "offset": 20, "window_id": window_id},
)
data = expired.json()
assert data["window_reset"] is True
assert data["offset"] == 0
assert data["window_id"] != window_id
assert len(data["memories"]) == 20
assert len(sdk.search_calls) == 2
# A window searched for other words is not reused for this query.
mismatched = await client.get(
"/api/v2/memory-library",
params={"query": "note", "offset": 20, "window_id": data["window_id"]},
)
assert mismatched.json()["window_reset"] is True
assert mismatched.json()["offset"] == 0
unknown = await client.get(
"/api/v2/memory-library",
params={"query": "deploy", "window_id": "never-issued"},
)
assert unknown.json()["window_reset"] is True
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_keyword_filter_reads_experience_and_fact_text(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "lunch preferences"))
sdk.add_productivity(
"experience",
experience_row("exp-1", "Fix flaky tests", "Pin the pytest seed"),
)
sdk.add_productivity("fact", fact_row("fact-1", "The pytest suite runs in CI"))
response = await client.get(
"/api/v2/memory-library", params={"query": "PYTEST", "source": "codebuddy"}
)
body = response.json()
assert body["total"] == 2
assert sdk.search_calls[0][1]["include_sources"] == ["codebuddy"]
by_id = {item["memory_id"]: item for item in body["memories"]}
experience = by_id["exp-1"]
assert experience["scene"] == "working"
assert experience["kind"] == "experience"
assert experience["variant"] == "working"
assert experience["task"] == "Fix flaky tests"
assert experience["solution"] == "Pin the pytest seed"
assert experience["content"] == "Fix flaky tests\nPin the pytest seed"
assert experience["status"] == "active"
assert experience["updated_at"] > experience["created_at"]
fact = by_id["fact-1"]
assert (fact["scene"], fact["kind"], fact["variant"]) == ("coding", "fact", "coding")
assert fact["content"] == "The pytest suite runs in CI"
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_full_search_window_is_reported(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
for index in range(205):
sdk.add_chat(chat_row(f"chat-{index:03d}", f"note {index}"))
body = (await client.get("/api/v2/memory-library", params={"query": "note"})).json()
assert body["search_window"] == 200
assert body["total"] == 200
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_empty_query_lists_all_scenes_with_source_filter_and_totals(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "cursor chat", source="cursor"))
sdk.add_chat(chat_row("chat-2", "codebuddy chat", source="codebuddy"))
sdk.add_productivity("experience", experience_row("exp-w", "task w", "solution w"))
sdk.add_productivity(
"experience", experience_row("exp-c", "task c", "solution c", variant="coding")
)
response = await client.get("/api/v2/memory-library")
body = response.json()
assert response.status_code == 200
assert body["limit"] == 20
assert body["total"] == 4
assert body["totals"] == {"chat": 2, "working": 1, "coding": 1}
assert body["window_id"] is None
assert [item["scene"] for item in body["memories"]] == [
"working",
"coding",
"chat",
"chat",
]
call = sdk.list_calls[0]
assert call["scene"] == "all"
assert call["include_sources"] is None
assert call["statuses"] == ["active"]
assert (call["offset"], call["limit"]) == (0, 20)
filtered = await client.get(
"/api/v2/memory-library", params={"source": "codebuddy", "offset": 1}
)
data = filtered.json()
assert sdk.list_calls[1]["include_sources"] == ["codebuddy"]
assert sdk.list_calls[1]["offset"] == 1
assert data["total"] == 3
assert data["offset"] == 1
assert [item["memory_id"] for item in data["memories"]] == ["exp-c", "chat-2"]
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_detail_edit_delete_pass_kind_and_variant(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "hello"))
sdk.add_productivity("experience", experience_row("exp-1", "Old task", "Old solution"))
sdk.add_productivity("fact", fact_row("fact-1", "Old statement"))
detail = await client.get(
"/api/v2/memories/exp-1", params={"kind": "experience", "variant": "working"}
)
assert detail.status_code == 200
record = detail.json()
assert (record["scene"], record["kind"], record["variant"]) == (
"working",
"experience",
"working",
)
assert record["task"] == "Old task"
assert record["solution"] == "Old solution"
assert record["content"] == "Old task\nOld solution"
assert sdk.get_calls[-1] == ("exp-1", "experience", "working")
# Chat detail with no namespace keeps the plain SDK call.
chat = await client.get("/api/v2/memories/chat-1")
assert chat.json()["kind"] == "chat"
assert chat.json()["scene"] == "chat"
assert sdk.get_calls[-1] == ("chat-1", None, None)
edited = await client.put(
"/api/v2/memories/exp-1",
json={
"content": "New solution",
"task": "New task",
"kind": "experience",
"variant": "working",
},
)
assert edited.status_code == 200
assert sdk.update_calls[-1] == (
"exp-1",
"New solution",
"experience",
"working",
"New task",
)
assert edited.json()["solution"] == "New solution"
assert edited.json()["task"] == "New task"
fact = await client.put(
"/api/v2/memories/fact-1",
json={"content": "New statement", "kind": "fact", "variant": "coding"},
)
assert fact.status_code == 200
assert fact.json()["content"] == "New statement"
refused = await client.put(
"/api/v2/memories/fact-1",
json={"content": "x", "task": "y", "kind": "fact"},
)
assert refused.status_code == 422
deleted = await client.delete(
"/api/v2/memories/fact-1", params={"kind": "fact", "variant": "coding"}
)
assert deleted.status_code == 204
assert sdk.delete_calls[-1] == ("fact-1", "fact", "coding")
bad = await client.get("/api/v2/memories/exp-1", params={"kind": "nope"})
assert bad.status_code == 422
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_ambiguous_memory_id_is_a_conflict(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("dup", "chat text"))
sdk.add_productivity("experience", experience_row("dup", "task", "solution"))
for method in ("get", "delete"):
response = await getattr(client, method)("/api/v2/memories/dup")
assert response.status_code == 409
assert response.json()["detail"] == "memory_id_ambiguous"
update = await client.put("/api/v2/memories/dup", json={"content": "x"})
assert update.status_code == 409
assert update.json()["detail"] == "memory_id_ambiguous"
resolved = await client.get("/api/v2/memories/dup", params={"kind": "chat"})
assert resolved.status_code == 200
assert resolved.json()["content"] == "chat text"
run_with_sdk(tmp_path, monkeypatch, scenario)
+34 -18
View File
@@ -313,6 +313,16 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
include_sources = kwargs.get("include_sources") or []
if include_sources:
rows = [row for row in rows if row["source"] in include_sources]
if kwargs.get("scene") == "all":
return {
"scene": "all",
"memories": [{**row, "scene": "chat", "kind": "chat"} for row in rows],
"total": len(rows),
"totals": {"chat": len(rows), "working": 0, "coding": 0},
"errors": {},
"limit": kwargs["limit"],
"offset": kwargs["offset"],
}
return {
"vdb": {
"memories": rows,
@@ -325,25 +335,28 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
def search(self, query, **kwargs):
search_calls.append((query, kwargs))
return {
"memories": {
"schema": [],
"proactive": [],
"normal": [
{
"memory_id": "release",
"content": "Release plan",
"score": 0.9,
},
{
"memory_id": "retired",
"content": "Release plan, first draft",
"score": 0.8,
},
],
}
"scene": "all",
"memories": [
{
"memory_id": "release",
"content": "Release plan",
"score": 0.9,
"scene": "chat",
"kind": "chat",
},
{
"memory_id": "retired",
"content": "Release plan, first draft",
"score": 0.8,
"scene": "chat",
"kind": "chat",
},
],
"lanes": {"chat": 2, "working": 0, "coding": 0},
"errors": {},
}
def get(self, memory_id):
def get(self, memory_id, **_namespace):
return records.get(memory_id)
def delete(self, memory_id):
@@ -375,7 +388,9 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
assert [item["memory_id"] for item in body["memories"]] == ["release"]
assert search_calls[0][0] == "release"
assert search_calls[0][1]["include_sources"] == ["cursor"]
assert search_calls[0][1]["limit"] == 20
assert search_calls[0][1]["limit"] == 200
assert search_calls[0][1]["scene"] == "all"
assert search_calls[0][1]["rerank"] is False
assert search_calls[0][1]["intention_limit"] == 0
assert search_calls[0][1]["min_score"] == 0.0
assert list_calls == []
@@ -397,6 +412,7 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
assert listed.json()["total"] == 1
assert deleted == []
assert list_calls[0]["limit"] == 100
assert list_calls[0]["scene"] == "all"
assert "sdk_memory_invalid_rows_skipped count=1" in caplog.messages
graph = await client.get("/api/v2/memories/graph")
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
assert manifest["bundle_id"] == "migration-1"
assert manifest["sdk_namespace"] == "governed-v2"
assert manifest["runtime_build"] == "build-13"
assert manifest["snapshot_sdk_version"] == "2.0.5"
assert manifest["snapshot_sdk_version"] == "2.0.6"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
+4 -4
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.5"
version = "2.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chromadb" },
@@ -1008,9 +1008,9 @@ dependencies = [
{ name = "scikit-learn" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/56/7a279122bb56b067c390b748b8a83b6a90bb2a8f237c1d3989b6317681cd/hy_memory-2.0.5.tar.gz", hash = "sha256:bfb1e9e9815377477702327fa318331d555b8256a67b5bad8d70d6a682bd9a4f", size = 839060, upload-time = "2026-10-22T12:59:30.502Z" }
sdist = { url = "https://files.pythonhosted.org/packages/18/98/312ac8bcbe41e63200a93d296300b2e6748e1a532dad01f009dad57ba408/hy_memory-2.0.6.tar.gz", hash = "sha256:d0f54d54867ee7e722a8dd1a88a00c0576904435f15ae7899fe640840bff74bc", size = 855200, upload-time = "2026-10-27T07:53:10.022Z" }
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" },
{ url = "https://files.pythonhosted.org/packages/27/f1/d4866cffe426d77b857168cc694b15df866f3d9f45152c17c148e1595db4/hy_memory-2.0.6-py3-none-any.whl", hash = "sha256:b0e666f8e89d1763bb31c0e67502df74434134a638ff6fd1912de9e936be2483", size = 918955, upload-time = "2026-10-27T07:53:07.686Z" },
]
[[package]]
@@ -1837,7 +1837,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "filelock", specifier = ">=3.32.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "hy-memory", specifier = "==2.0.5" },
{ name = "hy-memory", specifier = "==2.0.6" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
+38
View File
@@ -589,6 +589,44 @@ it("names the SDK's no-source bucket 未识别来源, apart from a source under
expect(screen.getByText("只在最相关的 500 条记忆里查找关键词")).toBeTruthy();
});
it("badges each listed memory with its scene and pages 20 at a time", () => {
const items = ["对话一条", "工作经验", "编码事实"].map((content, i) => ({
...memories[0],
memory_id: `x${i}`,
content,
})) as GovernedMemory[];
const view = pagedView({
items,
total: 45,
offset: 20,
limit: 20,
sdk_memory_refs: {
x0: { scene: "chat", kind: "chat", variant: null },
x1: { scene: "working", kind: "experience", variant: "working" },
x2: { scene: "coding", kind: "fact", variant: "coding" },
},
});
render(<Host simplified items={items} libraryView={view} />);
const list = screen.getByRole("list", { name: "地图记忆列表" });
expect(
within(list).getByRole("button", { name: /对话一条/ }).textContent,
).toContain("对话");
expect(
within(list).getByRole("button", { name: /工作经验/ }).textContent,
).toContain("工作");
expect(
within(list).getByRole("button", { name: /编码事实/ }).textContent,
).toContain("编码");
expect(screen.getByText(/第 2 \/ 3 页/)).toBeTruthy();
// A short page (a row deleted since the search) still offers the next page.
const next = screen.getByRole("button", { name: "下一页记忆" });
expect((next as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(next);
expect(view.onPage).toHaveBeenCalledWith(40);
fireEvent.click(screen.getByRole("button", { name: "上一页记忆" }));
expect(view.onPage).toHaveBeenCalledWith(0);
});
it("says a filtered source is empty rather than that nothing matched", () => {
render(
<Host
+21 -2
View File
@@ -7,6 +7,7 @@ import type {
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SdkMemoryScene,
SourceResponse,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
@@ -151,6 +152,13 @@ function sourceStamp(
return dayStamp(fallback, locale);
}
/** The list's type badge for an SDK memory's scene. */
const SCENE_LABELS: Record<SdkMemoryScene, string> = {
chat: "对话",
working: "工作",
coding: "编码",
};
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
const {
@@ -787,6 +795,18 @@ export function MemoryMap(props: Props) {
>
<span className={`map-state ${m.status}`} aria-hidden="true" />
<span className="map-memory-content">{m.content}</span>
{library?.page?.sdk_memory_refs?.[m.memory_id] ? (
<span
className="map-memory-scene"
data-scene={library.page.sdk_memory_refs[m.memory_id].scene}
>
{text(
SCENE_LABELS[
library.page.sdk_memory_refs[m.memory_id].scene
],
)}
</span>
) : null}
{m.tags.length ? (
<span className="map-memory-tags wb-tags">
{m.tags.map((tag) => (
@@ -894,8 +914,7 @@ export function MemoryMap(props: Props) {
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
library.page.offset + library.page.items.length >=
library.page.total
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
clear();
+3 -2
View File
@@ -226,7 +226,8 @@ it("shows the three-column surface without four modes or a management button", a
source: undefined,
query: undefined,
offset: 0,
limit: 100,
limit: 20,
windowId: null,
},
expect.any(AbortSignal),
);
@@ -240,7 +241,7 @@ it("shows the three-column surface without four modes or a management button", a
expect(reads.sdkLibrary).toHaveBeenCalledWith({
statuses: ["active"],
offset: 0,
limit: 100,
limit: 20,
}),
);
fireEvent(window, new Event("one-memory:manage"));
@@ -59,9 +59,14 @@ vi.mock("./MemoryMap", () => ({
onSave(event: { preventDefault(): void }): void;
onDelete(): void;
libraryView?: {
page: { source_ids_by_memory?: Record<string, string[]> } | null;
page: {
source_ids_by_memory?: Record<string, string[]>;
offset: number;
} | null;
unattributedSource?: string;
onSource(value: string): void;
onQuery(value: string): void;
onPage(offset: number): void;
resetPage(): void;
};
}) => (
@@ -85,6 +90,31 @@ vi.mock("./MemoryMap", () => ({
</span>
<span data-testid="map-busy">{String(busy)}</span>
<span data-testid="map-detail">{detail?.memory.memory_id ?? ""}</span>
<span data-testid="map-offset">{libraryView?.page?.offset ?? ""}</span>
<button type="button" onClick={() => onOpenMemory("e1")}>
open e1
</button>
<button
type="button"
onClick={() => {
libraryView?.onQuery("deploy");
libraryView?.resetPage();
}}
>
search deploy
</button>
<button
type="button"
onClick={() => {
libraryView?.onQuery("other");
libraryView?.resetPage();
}}
>
search other
</button>
<button type="button" onClick={() => libraryView?.onPage(20)}>
page 2
</button>
<button type="button" onClick={() => onOpenMemory("a")}>
open a
</button>
@@ -178,7 +208,8 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
source: undefined,
query: undefined,
offset: 0,
limit: 100,
limit: 20,
windowId: null,
},
expect.any(AbortSignal),
);
@@ -236,7 +267,9 @@ it("opens an SDK memory in place instead of switching away from the map", async
);
fireEvent.click(screen.getByText("open a"));
await waitFor(() => expect(api.sdkMemory).toHaveBeenCalledWith("a"));
await waitFor(() =>
expect(api.sdkMemory).toHaveBeenCalledWith("a", undefined),
);
// The inspector stays on the map; the reader is never moved to a second list.
expect(screen.getByTestId("map").hidden).toBe(false);
expect(screen.getByTestId("sdk-library").textContent).toBe("");
@@ -291,6 +324,7 @@ it("saves and deletes an opened memory through v2 without a stale-read block", a
"a",
"memory a",
expect.any(String),
undefined,
),
);
await waitFor(() =>
@@ -300,7 +334,11 @@ it("saves and deletes an opened memory through v2 without a stale-read block", a
fireEvent.click(screen.getByText("delete"));
fireEvent.click(await screen.findByRole("button", { name: "确认删除" }));
await waitFor(() =>
expect(api.deleteSdkMemory).toHaveBeenCalledWith("a", expect.any(String)),
expect(api.deleteSdkMemory).toHaveBeenCalledWith(
"a",
expect.any(String),
undefined,
),
);
await waitFor(() =>
expect(screen.getByTestId("map-detail").textContent).toBe(""),
@@ -385,3 +423,153 @@ it("groups and draws SDK memories by source with the real map model", async () =
).toEqual(["a", "b"]);
expect(drawn.edges.length).toBeGreaterThan(0);
});
const experience = (memory_id: string): SdkMemoryRecord => ({
...record(memory_id),
content: "Fix flaky tests\nPin the seed",
layer: "experience",
scene: "coding",
kind: "experience",
variant: "coding",
task: "Fix flaky tests",
solution: "Pin the seed",
created_at: 1_761_000_000,
updated_at: 1_761_000_100,
});
it("opens, edits and deletes a coding experience by its kind and variant", async () => {
api.sdkLibrary.mockResolvedValue({
memories: [record("a"), experience("e1")],
total: 2,
limit: 20,
offset: 0,
});
api.sdkGraph.mockResolvedValue(null);
api.sdkMemory.mockResolvedValue(experience("e1"));
api.updateSdkMemory.mockResolvedValue({
...experience("e1"),
solution: "Pin the seed",
});
api.deleteSdkMemory.mockResolvedValue(undefined);
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
// A productivity memory has no graph node; the page still draws.
expect(screen.getByTestId("map-memories").textContent).toBe(
"a:confirmed,e1:confirmed",
);
fireEvent.click(screen.getByText("open e1"));
await waitFor(() =>
expect(api.sdkMemory).toHaveBeenCalledWith("e1", {
kind: "experience",
variant: "coding",
}),
);
await waitFor(() =>
expect(screen.getByTestId("map-detail").textContent).toBe("e1"),
);
// The editor holds the solution, not the joined task and solution.
fireEvent.click(screen.getByText("save"));
await waitFor(() =>
expect(api.updateSdkMemory).toHaveBeenCalledWith(
"e1",
"Pin the seed",
expect.any(String),
{ kind: "experience", variant: "coding" },
),
);
await waitFor(() =>
expect(screen.getByTestId("map-busy").textContent).toBe("false"),
);
fireEvent.click(screen.getByText("delete"));
fireEvent.click(await screen.findByRole("button", { name: "确认删除" }));
await waitFor(() =>
expect(api.deleteSdkMemory).toHaveBeenCalledWith("e1", expect.any(String), {
kind: "experience",
variant: "coding",
}),
);
});
it("pages a keyword search through one window and restarts when it resets", async () => {
sdkAuthority();
api.sdkGraph.mockResolvedValue(null);
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
// The server keeps window "w1" until it expires, then answers any page
// asked of it with page 1 of a fresh window "w2".
const live = new Set(["w1"]);
api.sdkLibrary.mockImplementation(
async (request: { offset: number; windowId?: string | null }) => {
const reset = !!request.windowId && !live.has(request.windowId);
const offset = reset ? 0 : request.offset;
const windowId = reset || live.has("w2") ? "w2" : "w1";
if (reset) live.add("w2");
return {
memories: [record(`${windowId}-${offset}`)],
total: 45,
limit: 20,
offset,
window_id: windowId,
window_reset: reset,
};
},
);
fireEvent.click(screen.getByText("search deploy"));
await waitFor(() =>
expect(screen.getByTestId("map-memories").textContent).toBe(
"w1-0:confirmed",
),
);
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ query: "deploy", offset: 0, windowId: null }),
expect.any(AbortSignal),
);
fireEvent.click(screen.getByText("page 2"));
await waitFor(() =>
expect(screen.getByTestId("map-memories").textContent).toBe(
"w1-20:confirmed",
),
);
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ query: "deploy", offset: 20, windowId: "w1" }),
expect.any(AbortSignal),
);
// Back to page 1 on the same window, then the window expires: asking for
// page 2 again restarts at page 1 of the new window.
fireEvent.click(screen.getByText("search deploy"));
await waitFor(() =>
expect(screen.getByTestId("map-offset").textContent).toBe("0"),
);
live.delete("w1");
fireEvent.click(screen.getByText("page 2"));
await waitFor(() =>
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ query: "deploy", offset: 0, windowId: "w2" }),
expect.any(AbortSignal),
),
);
await waitFor(() =>
expect(screen.getByTestId("map-memories").textContent).toBe(
"w2-0:confirmed",
),
);
expect(screen.getByTestId("map-offset").textContent).toBe("0");
// A new query drops the window.
fireEvent.click(screen.getByText("search other"));
await waitFor(() =>
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ query: "other", offset: 0, windowId: null }),
expect.any(AbortSignal),
),
);
});
+57 -9
View File
@@ -49,7 +49,13 @@ import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
import {
SDK_MEMORY_PAGE_SIZE,
sdkDetail,
sdkEditableText,
sdkLibraryPage,
sdkNamespace,
} from "./sdk-memory-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -188,6 +194,16 @@ export function MemorySpace({
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
const sdkSearchWindow = useRef<{ key: string; id: string | null }>({
key: "",
id: null,
});
// The page the list shows, for the namespace of a memory being opened.
const sdkPageRef = useRef<MemoryLibraryPage | null>(null);
sdkPageRef.current = sdkLibrary.page;
const sdkMapVisible = simplified && active && displayedMode === "map";
useEffect(() => {
if (!sdkMapVisible) return;
@@ -197,7 +213,10 @@ export function MemorySpace({
let cancelled = false;
let controller: AbortController | null = null;
const source = sdkLibrarySource || undefined;
const query = sdkLibraryQuery || undefined;
const query = sdkLibraryQuery.trim() || undefined;
const windowKey = JSON.stringify([query ?? "", source ?? ""]);
if (sdkSearchWindow.current.key !== windowKey)
sdkSearchWindow.current = { key: windowKey, id: null };
const read = async (first: boolean) => {
controller?.abort();
const request = new AbortController();
@@ -211,17 +230,37 @@ export function MemorySpace({
source,
query,
offset: sdkLibraryOffset,
limit: 100,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
),
// The overlay is decoration: the list stands without it.
// The overlay is decoration: the list stands without it. It reads
// chat memories only, newest first; a mixed page's chat rows are
// among the newest offset+page chat memories, so that prefix holds
// every relation the page can draw.
getSdkMemoryGraph(
{ source, offset: query ? 0 : sdkLibraryOffset, limit: 100 },
{
source,
offset: 0,
limit: query
? 100
: Math.min(
300,
Math.max(100, sdkLibraryOffset + SDK_MEMORY_PAGE_SIZE),
),
},
request.signal,
).catch(() => null),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
sdkSearchWindow.current = {
key: windowKey,
id: page.window_id ?? null,
};
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
@@ -547,12 +586,16 @@ export function MemorySpace({
// The inspector reads the memory through v2 and renders it in place.
// Staying here keeps the map's list, graph and detail where the design
// put them.
const result = sdkDetail(await getSdkMemory(memoryId));
const record = await getSdkMemory(
memoryId,
sdkNamespace(sdkPageRef.current?.sdk_memory_refs?.[memoryId]),
);
const result = sdkDetail(record);
if (epoch !== detailEpoch.current || result.memory.memory_id !== memoryId)
return;
setDetail(result);
setDetailLoad(null);
setDraft(result.memory.content);
setDraft(sdkEditableText(record));
} catch (reason) {
if (epoch === detailEpoch.current) {
setError(message(reason, t));
@@ -586,7 +629,11 @@ export function MemorySpace({
// An SDK memory is removed outright: there is no revision to undo.
const trace = traceUiAction("memory", "confirm");
try {
await deleteSdkMemory(detail.memory.memory_id, trace.traceId);
await deleteSdkMemory(
detail.memory.memory_id,
trace.traceId,
sdkNamespace(detail.sdk_ref),
);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
@@ -613,10 +660,11 @@ export function MemorySpace({
detail.memory.memory_id,
draft,
trace.traceId,
sdkNamespace(detail.sdk_ref),
);
trace.done("succeeded");
setDetail(sdkDetail(saved));
setDraft(saved.content);
setDraft(sdkEditableText(saved));
bumpSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
+132 -2
View File
@@ -84,14 +84,144 @@ describe("SDK memory library", () => {
await userEvent.type(input, "changed");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(updateSdkMemory).toHaveBeenCalledWith("memory-1", "changed"),
expect(updateSdkMemory).toHaveBeenCalledWith(
"memory-1",
"changed",
undefined,
undefined,
),
);
await userEvent.click(screen.getByRole("button", { name: "删除记忆" }));
await waitFor(() =>
expect(deleteSdkMemory).toHaveBeenCalledWith("memory-1"),
expect(deleteSdkMemory).toHaveBeenCalledWith(
"memory-1",
undefined,
undefined,
),
);
expect(screen.queryByText("撤销")).toBeNull();
expect(screen.queryByText(/版本/)).toBeNull();
expect(screen.queryByRole("button", { name: "已删除" })).toBeNull();
});
it("pages 20 at a time with a type badge for each scene", async () => {
const rows = Array.from({ length: 20 }, (_, index) => ({
...memory,
memory_id: `m-${index}`,
content: `row ${index}`,
...(index === 1
? {
scene: "working" as const,
kind: "experience" as const,
variant: "working" as const,
}
: index === 2
? {
scene: "coding" as const,
kind: "fact" as const,
variant: "coding" as const,
}
: { scene: "chat" as const, kind: "chat" as const }),
}));
getSdkMemoryLibrary.mockImplementation(
async ({ offset }: { offset: number }) => ({
memories: rows,
total: 45,
offset,
limit: 20,
}),
);
render(<SdkMemoryLibrary active />);
expect(await screen.findByText("第 1 / 3 页")).toBeTruthy();
expect(getSdkMemoryLibrary).toHaveBeenLastCalledWith({
statuses: ["active"],
offset: 0,
limit: 20,
});
const badges = document.querySelectorAll(".sdk-memory-scene");
expect([...badges].slice(0, 3).map((badge) => badge.textContent)).toEqual([
"对话",
"工作",
"编码",
]);
const previous = screen.getByRole("button", { name: "上一页" });
const next = screen.getByRole("button", { name: "下一页" });
expect((previous as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(next);
expect(await screen.findByText("第 2 / 3 页")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "下一页" }));
expect(await screen.findByText("第 3 / 3 页")).toBeTruthy();
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "下一页" }) as HTMLButtonElement)
.disabled,
).toBe(true),
);
expect(getSdkMemoryLibrary).toHaveBeenLastCalledWith({
statuses: ["active"],
offset: 40,
limit: 20,
});
await userEvent.click(screen.getByRole("button", { name: "上一页" }));
expect(await screen.findByText("第 2 / 3 页")).toBeTruthy();
});
it("edits an experience's solution and task by its kind and variant", async () => {
const experience = {
...memory,
memory_id: "exp-1",
content: "Fix tests\nPin seed",
layer: "experience",
scene: "coding" as const,
kind: "experience" as const,
variant: "coding" as const,
task: "Fix tests",
solution: "Pin seed",
};
getSdkMemoryLibrary.mockResolvedValue({
memories: [experience],
total: 1,
offset: 0,
limit: 20,
});
getSdkMemory.mockResolvedValue(experience);
updateSdkMemory.mockImplementation((_id, content, _trace, edit) =>
Promise.resolve({
...experience,
solution: content,
task: edit?.task ?? experience.task,
}),
);
render(<SdkMemoryLibrary active />);
await userEvent.click(
await screen.findByRole("button", { name: /Fix tests/ }),
);
expect(getSdkMemory).toHaveBeenCalledWith("exp-1", {
kind: "experience",
variant: "coding",
});
const solution = screen.getByRole("textbox", { name: "做法" });
expect((solution as HTMLTextAreaElement).value).toBe("Pin seed");
const task = screen.getByRole("textbox", { name: "任务" });
await userEvent.clear(task);
await userEvent.type(task, "Fix flaky tests");
await userEvent.clear(solution);
await userEvent.type(solution, "Pin the seed");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(updateSdkMemory).toHaveBeenCalledWith(
"exp-1",
"Pin the seed",
undefined,
{ kind: "experience", variant: "coding", task: "Fix flaky tests" },
),
);
await userEvent.click(screen.getByRole("button", { name: "删除记忆" }));
await waitFor(() =>
expect(deleteSdkMemory).toHaveBeenCalledWith("exp-1", undefined, {
kind: "experience",
variant: "coding",
}),
);
});
});
+101 -26
View File
@@ -1,4 +1,8 @@
import type { SdkMemoryRecord, SdkMemoryStatus } from "@one-memory/contracts";
import type {
SdkMemoryRecord,
SdkMemoryScene,
SdkMemoryStatus,
} from "@one-memory/contracts";
import { useCallback, useEffect, useRef, useState } from "react";
import {
deleteSdkMemory,
@@ -8,6 +12,18 @@ import {
} from "./api";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import {
SDK_MEMORY_PAGE_SIZE,
sdkEditableText,
sdkMemoryRef,
sdkNamespace,
} from "./sdk-memory-map";
const SCENE_LABELS = {
chat: "sdk.memory.scene.chat",
working: "sdk.memory.scene.working",
coding: "sdk.memory.scene.coding",
} as const satisfies Record<SdkMemoryScene, string>;
export function SdkMemoryLibrary({
active,
@@ -27,9 +43,11 @@ export function SdkMemoryLibrary({
const [total, setTotal] = useState(0);
const [selected, setSelected] = useState<SdkMemoryRecord | null>(null);
const [draft, setDraft] = useState("");
// Experiences only: the task, edited next to the solution in `draft`.
const [taskDraft, setTaskDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const limit = 100;
const limit = SDK_MEMORY_PAGE_SIZE;
const refresh = useCallback(async () => {
if (!active) return;
@@ -56,20 +74,32 @@ export function SdkMemoryLibrary({
void refresh();
}, [refresh]);
const open = useCallback(async (memoryId: string) => {
setBusy(true);
setError("");
try {
const memory = await getSdkMemory(memoryId);
setSelected(memory);
setDraft(memory.content);
} catch {
setError(translation.current("sdk.memory.detailLoadFailed"));
} finally {
setBusy(false);
}
const select = useCallback((memory: SdkMemoryRecord) => {
setSelected(memory);
setDraft(sdkEditableText(memory));
setTaskDraft(memory.task ?? "");
}, []);
const open = useCallback(
async (memoryId: string, listed?: SdkMemoryRecord) => {
setBusy(true);
setError("");
try {
select(
await getSdkMemory(
memoryId,
listed ? sdkNamespace(sdkMemoryRef(listed)) : undefined,
),
);
} catch {
setError(translation.current("sdk.memory.detailLoadFailed"));
} finally {
setBusy(false);
}
},
[select],
);
useEffect(() => {
if (active && focusedMemoryId) void open(focusedMemoryId);
}, [active, focusedMemoryId, open]);
@@ -79,8 +109,20 @@ export function SdkMemoryLibrary({
setBusy(true);
setError("");
try {
const memory = await updateSdkMemory(selected.memory_id, draft);
setSelected(memory);
const namespace = sdkNamespace(sdkMemoryRef(selected));
const task =
selected.kind === "experience" &&
taskDraft.trim() &&
taskDraft !== (selected.task ?? "")
? taskDraft
: undefined;
const memory = await updateSdkMemory(
selected.memory_id,
draft,
undefined,
namespace && { ...namespace, ...(task ? { task } : {}) },
);
select(memory);
await refresh();
onChanged?.();
} catch {
@@ -95,7 +137,11 @@ export function SdkMemoryLibrary({
setBusy(true);
setError("");
try {
await deleteSdkMemory(selected.memory_id);
await deleteSdkMemory(
selected.memory_id,
undefined,
sdkNamespace(sdkMemoryRef(selected)),
);
setSelected(null);
await refresh();
onChanged?.();
@@ -136,13 +182,22 @@ export function SdkMemoryLibrary({
<p role="status">{t("sdk.memory.loading")}</p>
) : null}
<ul>
{rows.map((memory) => (
<li key={memory.memory_id}>
<button type="button" onClick={() => void open(memory.memory_id)}>
{memory.content}
</button>
</li>
))}
{rows.map((memory) => {
const ref = sdkMemoryRef(memory);
return (
<li key={`${ref.scene}:${ref.kind}:${memory.memory_id}`}>
<span className="sdk-memory-scene" data-scene={ref.scene}>
{t(SCENE_LABELS[ref.scene])}
</span>
<button
type="button"
onClick={() => void open(memory.memory_id, memory)}
>
{memory.content}
</button>
</li>
);
})}
</ul>
<nav aria-label={t("sdk.memory.pagination")}>
<R6Button
@@ -152,6 +207,12 @@ export function SdkMemoryLibrary({
>
{t("sdk.memory.previous")}
</R6Button>
<span>
{t("sdk.memory.page", {
page: total === 0 ? 0 : Math.floor(offset / limit) + 1,
pages: Math.ceil(total / limit),
})}
</span>
<R6Button
variant="quiet"
disabled={busy || offset + limit >= total}
@@ -162,11 +223,25 @@ export function SdkMemoryLibrary({
</nav>
{selected ? (
<aside aria-label={t("sdk.memory.detail")}>
<p>{selected.layer}</p>
<p>
{t(SCENE_LABELS[sdkMemoryRef(selected).scene])} · {selected.layer}
</p>
{selected.status === "active" ? (
<>
{selected.kind === "experience" ? (
<textarea
aria-label={t("sdk.memory.task")}
value={taskDraft}
disabled={busy}
onChange={(event) => setTaskDraft(event.target.value)}
/>
) : null}
<textarea
aria-label={t("sdk.memory.content")}
aria-label={
selected.kind === "experience"
? t("sdk.memory.solution")
: t("sdk.memory.content")
}
value={draft}
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
+67 -2
View File
@@ -1,16 +1,28 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
const { get, put, del } = vi.hoisted(() => ({
get: vi.fn(),
put: vi.fn(),
del: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: () => ({
get,
put,
delete: del,
interceptors: { request: { use: vi.fn() } },
}),
},
}));
import { getHomeSummary, getSdkMemoryLibrary } from "./api";
import {
deleteSdkMemory,
getHomeSummary,
getSdkMemory,
getSdkMemoryLibrary,
updateSdkMemory,
} from "./api";
describe("paged memory library client", () => {
beforeEach(() => vi.clearAllMocks());
@@ -35,6 +47,59 @@ describe("paged memory library client", () => {
expect(params.get("query")).toBe("release");
});
it("pages 20 at a time and sends the search window only with a query", async () => {
get.mockResolvedValue({
data: { memories: [], total: 0, offset: 0, limit: 20 },
});
await getSdkMemoryLibrary({ query: "deploy", offset: 20, windowId: "w1" });
let params = get.mock.calls[0][1].params as URLSearchParams;
expect(params.get("limit")).toBe("20");
expect(params.get("offset")).toBe("20");
expect(params.get("window_id")).toBe("w1");
await getSdkMemoryLibrary({ windowId: "w1" });
params = get.mock.calls[1][1].params as URLSearchParams;
expect(params.has("window_id")).toBe(false);
expect(params.get("limit")).toBe("20");
});
it("names an experience's kind and variant on get, update and delete", async () => {
get.mockResolvedValue({ data: { memory_id: "e1" } });
put.mockResolvedValue({ data: { memory_id: "e1" } });
del.mockResolvedValue({});
const namespace = { kind: "experience", variant: "coding" } as const;
await getSdkMemory("e1", namespace);
expect(get).toHaveBeenCalledWith("/api/v2/memories/e1", {
params: { kind: "experience", variant: "coding" },
});
await getSdkMemory("c1");
expect(get).toHaveBeenLastCalledWith("/api/v2/memories/c1");
await updateSdkMemory("e1", "new solution", "trace-1", {
...namespace,
task: "new task",
});
expect(put).toHaveBeenCalledWith(
"/api/v2/memories/e1",
{
content: "new solution",
kind: "experience",
variant: "coding",
task: "new task",
},
{ headers: { "X-One-Memory-Trace-Id": "trace-1" } },
);
await deleteSdkMemory("e1", "trace-2", namespace);
expect(del).toHaveBeenCalledWith("/api/v2/memories/e1", {
headers: { "X-One-Memory-Trace-Id": "trace-2" },
params: { kind: "experience", variant: "coding" },
});
await deleteSdkMemory("c1");
expect(del).toHaveBeenLastCalledWith("/api/v2/memories/c1", undefined);
});
it("forwards cancellation for inactive or superseded views", async () => {
const controller = new AbortController();
get.mockResolvedValueOnce({
+52 -8
View File
@@ -25,9 +25,11 @@ import {
type ModelProfileInput,
type ModelProfilePatch,
type OnboardingState,
type SdkMemoryKind,
type SdkMemoryPage,
type SdkMemoryRecord,
type SdkMemoryStatus,
type SdkMemoryVariant,
type SdkRawContextItem,
type SdkRawPage,
type SourceDirectoryCandidate,
@@ -63,6 +65,7 @@ import {
recoverableFileUpload,
} from "./file-upload-client";
import { recoverableProbe } from "./model-probe-client";
import { SDK_MEMORY_PAGE_SIZE } from "./sdk-memory-map";
export const DEFAULT_CORE_URL = "http://127.0.0.1:8765";
@@ -694,13 +697,16 @@ export async function getSdkMemoryLibrary(
source,
query,
offset = 0,
limit = 100,
limit = SDK_MEMORY_PAGE_SIZE,
windowId,
}: {
statuses?: SdkMemoryStatus[];
source?: string;
query?: string;
offset?: number;
limit?: number;
/** Keyword search: the window a previous page returned. */
windowId?: string | null;
} = {},
signal?: AbortSignal,
): Promise<SdkMemoryPage> {
@@ -710,7 +716,10 @@ export async function getSdkMemoryLibrary(
});
for (const status of statuses) params.append("statuses", status);
if (source) params.set("source", source);
if (query?.trim()) params.set("query", query.trim());
if (query?.trim()) {
params.set("query", query.trim());
if (windowId) params.set("window_id", windowId);
}
const response = await client.get<SdkMemoryPage>("/api/v2/memory-library", {
params,
signal,
@@ -718,10 +727,32 @@ export async function getSdkMemoryLibrary(
return response.data;
}
export async function getSdkMemory(memoryId: string): Promise<SdkMemoryRecord> {
const response = await client.get<SdkMemoryRecord>(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
);
/** The namespace of an SDK memory, for get/update/delete. Chat memories need
* none; an experience or fact names its kind and working/coding variant. */
export interface SdkMemoryNamespace {
kind?: SdkMemoryKind;
variant?: SdkMemoryVariant | null;
}
function namespaceParams(
namespace: SdkMemoryNamespace | undefined,
): Record<string, string> | undefined {
if (!namespace?.kind && !namespace?.variant) return undefined;
const params: Record<string, string> = {};
if (namespace.kind) params.kind = namespace.kind;
if (namespace.variant) params.variant = namespace.variant;
return params;
}
export async function getSdkMemory(
memoryId: string,
namespace?: SdkMemoryNamespace,
): Promise<SdkMemoryRecord> {
const params = namespaceParams(namespace);
const path = `/api/v2/memories/${encodeURIComponent(memoryId)}`;
const response = params
? await client.get<SdkMemoryRecord>(path, { params })
: await client.get<SdkMemoryRecord>(path);
return response.data;
}
@@ -729,10 +760,17 @@ export async function updateSdkMemory(
memoryId: string,
content: string,
traceId?: string,
/** An experience's `content` is its solution; `task` replaces the task. */
edit?: SdkMemoryNamespace & { task?: string },
): Promise<SdkMemoryRecord> {
const body: Record<string, string> = { content };
if (edit?.kind) body.kind = edit.kind;
if (edit?.variant) body.variant = edit.variant;
if (edit?.task !== undefined && edit.kind === "experience")
body.task = edit.task;
const response = await client.put<SdkMemoryRecord>(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
{ content },
body,
// A rewrite is a write; carry the trace id so the ui.action / ui.result
// pair joins the kernel legs in one chain.
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
@@ -743,10 +781,16 @@ export async function updateSdkMemory(
export async function deleteSdkMemory(
memoryId: string,
traceId?: string,
namespace?: SdkMemoryNamespace,
): Promise<void> {
const params = namespaceParams(namespace);
const config = {
...(traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : {}),
...(params ? { params } : {}),
};
await client.delete(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
Object.keys(config).length ? config : undefined,
);
}
+12
View File
@@ -855,6 +855,18 @@ button.rail-button.active::before {
font-size: 11px;
color: var(--tx3);
}
/* Which part of the library the memory is from: chat, work or coding. */
.map-memory-scene {
grid-column: 2;
justify-self: start;
align-self: flex-start;
padding: 0 6px;
border: 0.5px solid var(--bd);
border-radius: 3px;
color: var(--tx3);
font-size: 10px;
line-height: 1.6;
}
.map-memory-made {
display: block;
opacity: 0.72;
+56 -1
View File
@@ -1,7 +1,13 @@
import type { SdkMemoryRecord } from "@one-memory/contracts";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { projectPagedSourceMap } from "./memory-map-model";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
import {
sdkDetail,
sdkEditableText,
sdkLibraryPage,
sdkMemoryRef,
sdkNamespace,
} from "./sdk-memory-map";
const NOW = Date.UTC(2026, 9, 25) / 1_000;
@@ -85,3 +91,52 @@ it("carries a full search window through to the list", () => {
}).search_window,
).toBe(500);
});
it("carries each memory's scene and namespace, and edits an experience's solution", () => {
const experience = record({
memory_id: "e",
content: "Fix tests\nPin seed",
layer: "experience",
scene: "working",
kind: "experience",
variant: "working",
task: "Fix tests",
solution: "Pin seed",
created_at: NOW - 86_400,
updated_at: NOW - 3_600,
});
const page = sdkLibraryPage({
memories: [record({ memory_id: "c" }), experience],
total: 2,
limit: 20,
offset: 0,
window_id: "w1",
window_reset: true,
});
expect(page.sdk_memory_refs).toEqual({
c: { scene: "chat", kind: "chat", variant: null },
e: { scene: "working", kind: "experience", variant: "working" },
});
expect(page.window_id).toBe("w1");
expect(page.window_reset).toBe(true);
// An experience has no conversation time to show as "imported".
expect(page.source_time_by_memory?.e).toBeUndefined();
expect(page.items[1]).toMatchObject({
kind: "working",
updated_at: new Date((NOW - 3_600) * 1_000).toISOString(),
});
const detail = sdkDetail(experience);
expect(detail.sdk_ref).toEqual({
scene: "working",
kind: "experience",
variant: "working",
});
expect(sdkNamespace(detail.sdk_ref)).toEqual({
kind: "experience",
variant: "working",
});
expect(sdkNamespace(sdkMemoryRef(record({})))).toBeUndefined();
expect(sdkEditableText(experience)).toBe("Pin seed");
expect(sdkEditableText(record({}))).toBe("memory");
});
+56 -5
View File
@@ -5,9 +5,45 @@ import type {
MemoryLibraryPage,
SdkMemoryPage,
SdkMemoryRecord,
SdkMemoryRef,
} from "@one-memory/contracts";
function kindOf(layer: string): MemoryKind {
/** The SDK memory panel's page size (the API still accepts 1..500). */
export const SDK_MEMORY_PAGE_SIZE = 20;
/** Scene, kind and namespace of an SDK memory. Records from a Core before
* the all-scene library carry none of these and were all chat. */
export function sdkMemoryRef(record: SdkMemoryRecord): SdkMemoryRef {
const kind = record.kind ?? "chat";
if (kind === "chat") return { scene: "chat", kind, variant: null };
const variant =
record.variant ?? (record.scene === "coding" ? "coding" : "working");
return { scene: variant, kind, variant };
}
/** What get/update/delete name for a memory: nothing for chat, so a chat
* memory is addressed exactly as before; kind+variant for the rest. */
export function sdkNamespace(
ref: SdkMemoryRef | undefined,
):
| { kind: SdkMemoryRef["kind"]; variant: SdkMemoryRef["variant"] }
| undefined {
return ref && ref.kind !== "chat"
? { kind: ref.kind, variant: ref.variant }
: undefined;
}
/** The text an edit replaces: an experience's solution, otherwise content. */
export function sdkEditableText(record: SdkMemoryRecord): string {
return record.kind === "experience" && typeof record.solution === "string"
? record.solution
: record.content;
}
function kindOf(record: SdkMemoryRecord): MemoryKind {
const scene = sdkMemoryRef(record).scene;
if (scene !== "chat") return scene;
const layer = record.layer;
if (layer === "l6_schema") return "schema";
if (layer === "l7_intention") return "intention";
if (layer.startsWith("coding")) return "coding";
@@ -52,11 +88,14 @@ function sourceOf(record: SdkMemoryRecord): string {
* stay where the design put it instead of sending the reader to another view.
*/
export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
const created = stamp(record.gmt_created ?? record.memory_at);
const created = stamp(
record.gmt_created ?? record.created_at ?? record.memory_at,
);
const updated = stamp(record.updated_at) || created;
return {
memory_id: record.memory_id,
user_id: record.user_id,
kind: kindOf(record.layer),
kind: kindOf(record),
status:
record.status === "active"
? "confirmed"
@@ -75,7 +114,7 @@ export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
// renders this placeholder.
revision: 0,
created_at: created,
updated_at: created,
updated_at: updated,
deleted_at: null,
};
}
@@ -96,7 +135,11 @@ export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
sdk_status: record.status,
source_id: sourceOf(record),
sdk_memory_at: stamp(plausible(record.memory_at)) || null,
sdk_created_at: stamp(record.gmt_created) || null,
sdk_created_at: stamp(record.gmt_created ?? record.created_at) || null,
sdk_ref: sdkMemoryRef(record),
sdk_task: record.kind === "experience" ? (record.task ?? null) : null,
sdk_solution:
record.kind === "experience" ? (record.solution ?? null) : null,
};
}
@@ -114,8 +157,13 @@ export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
{ at: string; basis: "conversation" | "import" }
> = {};
const sourceIds: Record<string, string[]> = {};
const refs: Record<string, SdkMemoryRef> = {};
for (const record of page.memories) {
sourceIds[record.memory_id] = [sourceOf(record)];
refs[record.memory_id] = sdkMemoryRef(record);
// An experience or fact has no conversation time; the list shows when it
// was last updated instead of calling its creation an import.
if (refs[record.memory_id].kind !== "chat") continue;
const conversation = plausible(record.memory_at);
const at = conversation ?? plausible(record.gmt_created);
if (at === null) continue;
@@ -132,5 +180,8 @@ export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
search_window: page.search_window ?? null,
window_id: page.window_id ?? null,
window_reset: page.window_reset ?? false,
sdk_memory_refs: refs,
};
}
+1 -1
View File
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
- Linux x64/arm64 Runtime on native GHA runners.
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
- Windows is not supported.
- `hy-memory==2.0.5` is the current stable SDK dependency; the release
- `hy-memory==2.0.6` is the current stable SDK dependency; the release
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
See [`INSTALLATION.md`](INSTALLATION.md).
+45
View File
@@ -480,6 +480,14 @@ export interface MemoryLibraryPage {
/** Set on a search whose relevance window was full: matches were looked for
* only among this many most relevant memories, so `total` may undercount. */
search_window?: number | null;
/** SDK keyword search: the server-side result window to page with. */
window_id?: string | null;
/** SDK keyword search: the window passed in had expired, so this is page 1
* of a fresh search. */
window_reset?: boolean;
/** SDK pages: which scene each returned memory is from, and the namespace
* to name when opening, editing or deleting it. */
sdk_memory_refs?: Record<string, SdkMemoryRef>;
}
export interface MemoryLibraryQuery {
@@ -533,6 +541,11 @@ export interface MemoryDetail {
source_id?: string | null;
sdk_memory_at?: string | null;
sdk_created_at?: string | null;
/** SDK memories: the namespace to name on edit/delete. */
sdk_ref?: SdkMemoryRef;
/** SDK experiences: the task and solution, edited apart. */
sdk_task?: string | null;
sdk_solution?: string | null;
// Null for a memory nobody has rewritten: there is no index to be behind,
// and the interface should not explain a state the user never caused.
index_sync: MemoryIndexSync | null;
@@ -546,6 +559,20 @@ export type SdkMemoryStatus =
| "conflicted"
| "archived";
/** Which part of the library a memory lives in. */
export type SdkMemoryScene = "chat" | "working" | "coding";
/** What the memory is: a chat memory, a working/coding experience or fact. */
export type SdkMemoryKind = "chat" | "experience" | "fact";
/** The productivity namespace of an experience or fact. */
export type SdkMemoryVariant = "working" | "coding";
/** Enough to address one memory again: get/update/delete pass kind+variant. */
export interface SdkMemoryRef {
scene: SdkMemoryScene;
kind: SdkMemoryKind;
variant: SdkMemoryVariant | null;
}
export interface SdkMemoryRecord {
memory_id: string;
content: string;
@@ -563,6 +590,16 @@ export interface SdkMemoryRecord {
source: string | null;
memory_at: number | null;
gmt_created: number | null;
/** Absent only from pre-2.0.6 Core responses, which were all chat. */
scene?: SdkMemoryScene;
kind?: SdkMemoryKind;
variant?: SdkMemoryVariant | null;
/** Experiences: the task and the solution; `content` is both, joined. */
task?: string | null;
solution?: string | null;
/** Epoch seconds. */
created_at?: number | null;
updated_at?: number | null;
}
export interface SdkMemoryPage {
@@ -571,6 +608,14 @@ export interface SdkMemoryPage {
limit: number;
offset: number;
search_window?: number | null;
/** Keyword search: pass back with the next offset to page the same
* results. Null on a plain listing. */
window_id?: string | null;
/** Keyword search: the window_id sent had expired or did not match, the
* search ran again and this page is page 1 (offset 0) of the new window. */
window_reset?: boolean;
/** Plain listing: memories per scene under the same filters. */
totals?: Partial<Record<SdkMemoryScene, number>> | null;
}
export interface SdkUserProfileResponse {
@@ -58,6 +58,12 @@ export const currentChineseMessages = {
"sdk.memory.content": "记忆内容",
"sdk.memory.save": "保存修改",
"sdk.memory.delete": "删除记忆",
"sdk.memory.scene.chat": "对话",
"sdk.memory.scene.working": "工作",
"sdk.memory.scene.coding": "编码",
"sdk.memory.task": "任务",
"sdk.memory.solution": "做法",
"sdk.memory.page": "第 {page} / {pages} 页",
"sdk.raw.title": "SDK 原始对话",
"sdk.raw.searchLabel": "搜索原始对话",
@@ -197,6 +203,12 @@ export const currentEnglishMessages: Record<
"sdk.memory.content": "Memory content",
"sdk.memory.save": "Save changes",
"sdk.memory.delete": "Delete memory",
"sdk.memory.scene.chat": "Chat",
"sdk.memory.scene.working": "Work",
"sdk.memory.scene.coding": "Coding",
"sdk.memory.task": "Task",
"sdk.memory.solution": "Solution",
"sdk.memory.page": "Page {page} of {pages}",
"sdk.raw.title": "SDK original conversations",
"sdk.raw.searchLabel": "Search original conversations",
@@ -349,6 +361,12 @@ export const currentSpanishMessages: Record<
"sdk.memory.content": "Contenido de la memoria",
"sdk.memory.save": "Guardar cambios",
"sdk.memory.delete": "Eliminar memoria",
"sdk.memory.scene.chat": "Chat",
"sdk.memory.scene.working": "Trabajo",
"sdk.memory.scene.coding": "Programación",
"sdk.memory.task": "Tarea",
"sdk.memory.solution": "Solución",
"sdk.memory.page": "Página {page} de {pages}",
"sdk.raw.title": "Conversaciones originales del SDK",
"sdk.raw.searchLabel": "Buscar conversaciones originales",
@@ -1680,6 +1680,8 @@ const baseEnglishMessages = {
"memory:重试": "Retry",
"memory:删除": "Delete",
"memory:对话": "Conversation",
"memory:工作": "Work",
"memory:编码": "Coding",
"memory:保存修改": "Save changes",
"memory:对应记忆": "Related memory",
"memory:查看记忆": "View memories",
@@ -251,6 +251,8 @@ export const memoryChineseMessages = {
"memory:重试": "重试",
"memory:删除": "删除",
"memory:对话": "对话",
"memory:工作": "工作",
"memory:编码": "编码",
"memory:保存修改": "保存修改",
"memory:对应记忆": "对应记忆",
"memory:查看记忆": "查看记忆",
@@ -656,6 +658,8 @@ export const memorySpanishMessages: Record<
"memory:重试": "Reintentar",
"memory:删除": "Eliminar",
"memory:对话": "Conversación",
"memory:工作": "Trabajo",
"memory:编码": "Programación",
"memory:保存修改": "Guardar cambios",
"memory:对应记忆": "Memoria correspondiente",
"memory:查看记忆": "Ver memoria",
+1 -1
View File
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
HY_MEMORY_NAME = "hy-memory"
HY_MEMORY_VERSION = "2.0.5"
HY_MEMORY_VERSION = "2.0.6"
class SbomError(RuntimeError):
+1 -1
View File
@@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
"utf8",
);
assert.match(readme, /`hy-memory==2\.0\.5`/);
assert.match(readme, /`hy-memory==2\.0\.6`/);
assert.match(readme, /`v1\.0\.5` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /Linux 制品由 GitHub Actions/);
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
+3 -3
View File
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
dist_info = tmp_path / "hy_memory-2.0.5.dist-info"
dist_info = tmp_path / "hy_memory-2.0.6.dist-info"
licenses = dist_info / "licenses"
licenses.mkdir(parents=True)
(dist_info / "METADATA").write_text(
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
(
"Metadata-Version: 2.4",
"Name: hy-memory",
"Version: 2.0.5",
"Version: 2.0.6",
"License-Expression: Apache-2.0",
"",
)
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
notice = module.hy_memory_notice(dist_info)
assert notice["name"] == "hy-memory"
assert notice["version"] == "2.0.5"
assert notice["version"] == "2.0.6"
assert notice["license"] == "Apache-2.0"
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
assert "Licensed under the Apache License" in notice["text"]
+1 -1
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"
+665
View File
@@ -0,0 +1,665 @@
# -*- coding: utf-8 -*-
"""scene="all"(search / list)与生产力条目的 update / delete。
客户端用 object.__new__(HyMemoryClient) 搭最小骨架,公开 async_* 方法通过
__wrapped__ 直接调(不需要 client 自己的 loop 线程),与
test_list_and_stats_by_scene.py 同一套写法。
"""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from hy_memory.client import _SCENE_RRF_K, HyMemoryClient
from hy_memory.coding.store import CodingMemoryStore
from hy_memory.coding.types import CodingMemory, WorkingFact
from hy_memory.models.memory import MemoryLayer, MemoryStatus
from hy_memory.retrieval import AmbiguousMemoryIdError
BASE = datetime(2026, 10, 20, 8, 0, 0)
def _unwrapped(name):
return getattr(HyMemoryClient, name).__wrapped__
async def _store(coding_config, mock_embed, variant):
store = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant)
await store.initialize()
return store
async def _noop(*_args, **_kwargs):
return None
def _client(coding_config, mock_embed, stores, *, nodes=()):
client = object.__new__(HyMemoryClient)
client._config = coding_config
client._coding_stores = dict(stores)
client._embed_service = mock_embed
client._cache = None
client._graph_store = None
client._history_store = None
client._vector_store = _FakeVectorStore(list(nodes))
client._get_coding_writer = _noop
client._maybe_store_op_log = _noop
client._decorate_with_chain = lambda *a, **k: ("", [])
return client
class _FakeVectorStore:
def __init__(self, nodes):
self.nodes = {n.node_id: n for n in nodes}
async def list_by_user(self, *, layers=None, **_):
if layers == [MemoryLayer.L0_BASIC_INFO]:
return []
return list(self.nodes.values())
async def get_by_id(self, memory_id):
return self.nodes.get(memory_id)
def _chat_node(node_id, *, created, modified=None, source="cursor"):
return SimpleNamespace(
node_id=node_id, layer=MemoryLayer.L2_FACT, status=MemoryStatus.ACTIVE,
content=f"chat {node_id}", memory_at=None,
gmt_created=created, gmt_modified=modified, valid_from=None,
user_id="u", agent_id="a", session_id="s", source=source,
speculate=None, tags=[], custom={},
)
# ================================================================
# search(scene="all")
# ================================================================
def _chat_resp(*ids):
return {
"request_id": "r",
"memories": {
"normal": [
{"memory_id": i, "content": f"chat {i}", "score": 0.9 - n * 0.1,
"gmt_created": 1_700_000_000 + n, "source": "cursor"}
for n, i in enumerate(ids)
],
"schema": [],
"proactive": [],
},
}
def _prod_resp(facts=(), exps=()):
return {
"request_id": "r",
"scene": "productivity",
"facts": [
{"fact_id": f, "statement": f"stmt {f}", "score": 0.5,
"created_at": "2026-10-20T08:00:00", "source": "claude"}
for f in facts
],
"experiences": [
{"experience_id": e, "task": f"task {e}", "solution": f"sol {e}",
"score": 0.7, "updated_at": "2026-10-20T09:00:00", "source": "claude"}
for e in exps
],
}
def _fake_all_client(*, chat, working=None, coding=None, fail=(), enable=True,
seen=None):
"""_search_all_scenes 的依赖全部换成桩:只测融合与容错。"""
client = object.__new__(HyMemoryClient)
client._config = SimpleNamespace(coding=SimpleNamespace(enable=enable))
class _Embed:
calls = 0
async def embed(self, text):
_Embed.calls += 1
return [0.1, 0.2]
client._embed_service = _Embed()
seen = seen if seen is not None else {}
async def _impl(query, **kw):
seen["chat"] = kw
if "chat" in fail:
raise RuntimeError("chat backend down")
return chat
async def _coding_store(variant):
return object() if {"working": working, "coding": coding}[variant] is not None else None
async def _search_coding(target_query, **kw):
seen[kw["variant"]] = kw
if kw["variant"] in fail:
raise RuntimeError(f"{kw['variant']} store broken")
return {"working": working, "coding": coding}[kw["variant"]]
client._async_search_impl = _impl
client._coding_store_for_exact_get = _coding_store
client._search_coding = _search_coding
return client, seen
async def _search_all(client, **kw):
params = dict(
queries=None, search_mode="default", user_ids=["u"], agent_ids=[],
session_ids=[], include_sources=["claude"], exclude_sources=["mixed"],
limit=10, min_score=0.4, working_fact_min_score=None,
working_experience_min_score=None, schema_min_score=0.4, schema_limit=10,
intention_limit=0, created_after=None, reader="", workspace_id=None,
branch=None, request_id="req", layers=None, query_embedding=None,
rerank=True, target_query="q", lite_mode=False, t0=0.0,
)
params.update(kw)
return await HyMemoryClient._search_all_scenes(client, "q", **params)
async def test_search_all_fuses_by_lane_rank_with_rrf():
client, seen = _fake_all_client(
chat=_chat_resp("c1", "c2", "c3"),
working=_prod_resp(facts=["wf1"], exps=["we1", "we2"]),
coding=_prod_resp(exps=["ce1"]),
)
out = await _search_all(client, rerank=False)
assert out["scene"] == "all"
assert out["request_id"] == "req"
assert out["lanes"] == {"chat": 3, "working": 3, "coding": 1}
assert out["errors"] == {}
order = [(m["scene"], m["memory_id"]) for m in out["memories"]]
# 名次 1:chat → working → coding(同分按路序),名次 2:chat / working ...
assert order == [
("chat", "c1"), ("working", "wf1"), ("coding", "ce1"),
("chat", "c2"), ("working", "we1"),
("chat", "c3"), ("working", "we2"),
]
first = out["memories"][0]
assert first["score"] == round(1.0 / (_SCENE_RRF_K + 1), 6)
assert first["lane_score"] == pytest.approx(0.9)
assert first["lane_rank"] == 1 and first["kind"] == "chat"
fact = out["memories"][1]
assert fact["kind"] == "fact" and fact["content"] == "stmt wf1"
assert fact["lane_score"] == 0.5
exp = out["memories"][4]
assert exp["kind"] == "experience"
assert exp["memory_id"] == "we1"
assert exp["content"] == "task we1\nsol we1"
assert exp["score"] == round(1.0 / (_SCENE_RRF_K + 2), 6)
# 生产力时间戳是 naive UTC,按 UTC 换算成 epoch
assert exp["updated_at"] == int(
datetime(2026, 10, 20, 9, tzinfo=timezone.utc).timestamp()
)
# rerank / 来源过滤 / 阈值传到每一路,query 向量只算一次
for lane in ("chat", "working", "coding"):
assert seen[lane]["rerank"] is False
assert seen[lane]["include_sources"] == ["claude"]
assert seen[lane]["query_embedding"] == [0.1, 0.2]
assert seen["chat"]["scene"] == "chat"
assert client._embed_service.calls == 1
async def test_search_all_truncates_after_fusion():
client, _ = _fake_all_client(
chat=_chat_resp("c1", "c2", "c3"),
working=_prod_resp(exps=["we1", "we2"]),
coding=None,
)
out = await _search_all(client, limit=3)
assert [m["memory_id"] for m in out["memories"]] == ["c1", "we1", "c2"]
# coding 没写过:不贡献、不算错误
assert out["lanes"]["coding"] == 0
assert "coding" not in out["errors"]
async def test_search_all_isolates_a_failing_lane():
client, _ = _fake_all_client(
chat=_chat_resp("c1"),
working=_prod_resp(exps=["we1"]),
coding=_prod_resp(exps=["ce1"]),
fail=("working",),
)
out = await _search_all(client)
assert [m["memory_id"] for m in out["memories"]] == ["c1", "ce1"]
assert out["lanes"] == {"chat": 1, "working": 0, "coding": 1}
assert "store broken" in out["errors"]["working"]
client, _ = _fake_all_client(
chat=_chat_resp("c1"), working=_prod_resp(exps=["we1"]), fail=("chat",),
)
out = await _search_all(client)
assert [m["memory_id"] for m in out["memories"]] == ["we1"]
assert "chat backend down" in out["errors"]["chat"]
async def test_search_all_reports_disabled_productivity():
client, seen = _fake_all_client(
chat=_chat_resp("c1"), working=_prod_resp(exps=["we1"]), enable=False,
)
out = await _search_all(client)
assert [m["memory_id"] for m in out["memories"]] == ["c1"]
assert out["lanes"] == {"chat": 1, "working": 0, "coding": 0}
assert set(out["errors"]) == {"working", "coding"}
assert "working" not in seen
async def test_async_search_routes_scene_all(coding_config):
seen = {}
async def _spy(query, **kw):
seen.update(kw)
return {"scene": "all"}
client = object.__new__(HyMemoryClient)
client._config = coding_config
client._search_all_scenes = _spy
out = await _unwrapped("async_search")(
client, "q", scene=" ALL ", user_ids=["u"], include_sources=["Claude"],
rerank=False, limit=7,
)
assert out == {"scene": "all"}
assert seen["rerank"] is False and seen["limit"] == 7
assert seen["include_sources"] == ["claude"]
assert seen["exclude_sources"] == ["mixed"]
with pytest.raises(ValueError, match="all"):
await _unwrapped("async_search")(client, "q", scene="every", user_ids=["u"])
async def test_search_all_productivity_lanes_respect_sources(coding_config, mock_embed):
"""真 store:两路生产力检索按 source 过滤,经验与事实都过。"""
coding_config.coding.enable = True
working = await _store(coding_config, mock_embed, "working")
coding = await _store(coding_config, mock_embed, "coding")
try:
await working.insert(CodingMemory(
experience_id="w-claude", user_id="u", task="deploy service",
solution="kubectl apply", source="claude",
))
await working.insert(CodingMemory(
experience_id="w-cursor", user_id="u", task="deploy service",
solution="helm upgrade", source="cursor",
))
await working.insert_fact(WorkingFact(
fact_id="f-cursor", user_id="u", statement="deploy service",
source="cursor",
))
await coding.insert(CodingMemory(
experience_id="c-claude", user_id="u", task="deploy service",
solution="make deploy", source="claude",
))
client = _client(coding_config, mock_embed,
{"working": working, "coding": coding})
async def _chat(query, **kw):
return _chat_resp()
client._async_search_impl = _chat
out = await _search_all(
client, target_query="deploy service",
include_sources=["claude"], exclude_sources=["mixed"],
)
ids = {(m["scene"], m["memory_id"]) for m in out["memories"]}
assert ids == {("working", "w-claude"), ("coding", "c-claude")}
assert all(m["source"] == "claude" for m in out["memories"])
finally:
await working.close()
await coding.close()
async def test_productivity_search_rerank_false_skips_fact_lexical_rerank(
coding_config, mock_embed, monkeypatch,
):
from hy_memory.coding import lexical
calls = []
real = lexical.rerank
def _spy(*args, **kwargs):
calls.append(args)
return real(*args, **kwargs)
monkeypatch.setattr(lexical, "rerank", _spy)
working = await _store(coding_config, mock_embed, "working")
try:
for index in range(8):
await working.insert_fact(WorkingFact(
fact_id=f"f{index}", user_id="u", statement="deploy service",
))
client = _client(coding_config, mock_embed, {"working": working})
async def _run(rerank):
return await HyMemoryClient._search_coding(
client, "deploy service", user_ids=["u"], workspace_id=None,
branch=None, limit=2, request_id="r", t0=0.0,
variant="working", rerank=rerank,
)
await _run(False)
assert calls == []
await _run(True)
assert len(calls) == 1
finally:
await working.close()
# ================================================================
# list_memories(scene="all") 与生产力 list 的来源过滤
# ================================================================
async def test_list_productivity_scene_filters_sources_with_true_total(
coding_config, mock_embed,
):
coding = await _store(coding_config, mock_embed, "coding")
try:
for index, source in enumerate(["claude", "cursor", "claude", "Claude"]):
await coding.insert(CodingMemory(
experience_id=f"c{index}", user_id="u", task="T", solution="S",
source=source,
))
client = _client(coding_config, mock_embed, {"coding": coding})
list_memories = _unwrapped("async_list_memories")
page = await list_memories(
client, user_id="u", scene="coding", include_sources=["CLAUDE"], limit=2,
)
assert page["total"] == 3
assert len(page["experiences"]) == 2
assert all(e["source"] == "claude" for e in page["experiences"])
page = await list_memories(
client, user_id="u", scene="coding", exclude_sources=["claude"],
)
assert page["total"] == 1
assert [e["experience_id"] for e in page["experiences"]] == ["c1"]
unfiltered = await list_memories(client, user_id="u", scene="coding")
assert unfiltered["total"] == 4
finally:
await coding.close()
async def _seed_all(coding_config, mock_embed):
coding = await _store(coding_config, mock_embed, "coding")
working = await _store(coding_config, mock_embed, "working")
# 生产力时间戳按 UTC 写;这里直接给 updated_at 再改回去(insert 会刷新)
for eid, hours, source in (("c-old", 1, "claude"), ("c-new", 5, "cursor")):
await coding.insert(CodingMemory(
experience_id=eid, user_id="u", task=f"task {eid}", solution="S",
source=source,
))
await _set_updated(coding, eid, BASE + timedelta(hours=hours))
await working.insert(CodingMemory(
experience_id="w-mid", user_id="u", task="task w", solution="W",
source="claude",
))
await _set_updated(working, "w-mid", BASE + timedelta(hours=3))
# chat 节点是本地时间 naive;与 UTC 的经验比较时要换算到同一 epoch
utc_offset = datetime.now() - datetime.utcnow()
nodes = [
_chat_node("m-newest", created=BASE + utc_offset + timedelta(hours=6),
source="claude"),
_chat_node("m-edited", created=BASE + utc_offset,
modified=BASE + utc_offset + timedelta(hours=4), source="cursor"),
_chat_node("m-oldest", created=BASE + utc_offset, source="claude"),
]
return coding, working, nodes
async def _set_updated(store, experience_id, when):
def _sync():
with store._lock:
store._conn.execute(
"UPDATE coding_memory_meta SET updated_at = ? WHERE experience_id = ?",
(when.isoformat(), experience_id),
)
_sync()
async def test_list_all_merges_newest_first_and_paginates(coding_config, mock_embed):
coding, working, nodes = await _seed_all(coding_config, mock_embed)
try:
client = _client(coding_config, mock_embed,
{"coding": coding, "working": working}, nodes=nodes)
list_memories = _unwrapped("async_list_memories")
out = await list_memories(client, user_id="u", scene="all", limit=10)
assert out["scene"] == "all"
assert out["total"] == 6
assert out["totals"] == {"chat": 3, "working": 1, "coding": 2}
assert [(m["scene"], m["memory_id"]) for m in out["memories"]] == [
("chat", "m-newest"),
("coding", "c-new"),
("chat", "m-edited"),
("working", "w-mid"),
("coding", "c-old"),
("chat", "m-oldest"),
]
exp = out["memories"][1]
assert exp["kind"] == "experience" and exp["content"] == "task c-new\nS"
assert exp["status"] == "active"
assert exp["updated_at"] > out["memories"][2]["updated_at"]
assert out["memories"][0]["kind"] == "chat"
pages = []
for offset in (0, 2, 4):
page = await list_memories(
client, user_id="u", scene="all", limit=2, offset=offset,
)
assert page["total"] == 6
pages.extend(m["memory_id"] for m in page["memories"])
assert pages == [m["memory_id"] for m in out["memories"]]
with pytest.raises(ValueError, match="desc"):
await list_memories(client, user_id="u", scene="all", order="asc")
finally:
await coding.close()
await working.close()
async def test_list_all_applies_source_and_status_filters(coding_config, mock_embed):
coding, working, nodes = await _seed_all(coding_config, mock_embed)
try:
client = _client(coding_config, mock_embed,
{"coding": coding, "working": working}, nodes=nodes)
list_memories = _unwrapped("async_list_memories")
out = await list_memories(
client, user_id="u", scene="all", include_sources=["claude"],
)
assert [m["memory_id"] for m in out["memories"]] == [
"m-newest", "w-mid", "c-old", "m-oldest",
]
assert out["totals"] == {"chat": 2, "working": 1, "coding": 1}
assert out["total"] == 4
out = await list_memories(
client, user_id="u", scene="all", statuses=["archived"],
)
# 经验只有 ACTIVE:两路生产力不贡献;chat 假库不按状态过滤,原样返回
assert out["totals"]["working"] == 0 and out["totals"]["coding"] == 0
assert all(m["scene"] == "chat" for m in out["memories"])
finally:
await coding.close()
await working.close()
async def test_list_all_does_not_create_unwritten_scene_databases(coding_config, mock_embed):
client = _client(coding_config, mock_embed, {}, nodes=[
_chat_node("m1", created=BASE),
])
out = await _unwrapped("async_list_memories")(client, user_id="u", scene="all")
assert out["total"] == 1
assert out["totals"] == {"chat": 1, "working": 0, "coding": 0}
for variant in ("working", "coding"):
assert not CodingMemoryStore.database_exists(
coding_config, db_path=coding_config.coding.db_path, variant=variant,
)
# ================================================================
# update / delete 生产力条目
# ================================================================
async def test_update_experience_replaces_solution_and_keeps_task(coding_config, mock_embed):
working = await _store(coding_config, mock_embed, "working")
try:
await working.insert(CodingMemory(
experience_id="e1", user_id="u", task="deploy service",
search_keys=["rollout"], solution="old way",
))
await _set_updated(working, "e1", BASE)
client = _client(coding_config, mock_embed, {"working": working})
out = await _unwrapped("async_update")(client, "e1", "new way")
assert out["success"] is True
assert out["memory_id"] == "e1"
assert out["kind"] == "experience" and out["variant"] == "working"
assert {"request_id", "elapsed_ms"} <= set(out)
stored = await working.get_by_id("e1")
assert stored.task == "deploy service"
assert stored.solution == "new way"
assert stored.updated_at > BASE
# solution 不进向量:仍能按原 task 召回
hits = await working.search_by_query_embedding(
await mock_embed.embed("deploy service"), user_id="u",
)
assert hits and hits[0]["experience_id"] == "e1"
out = await _unwrapped("async_update")(
client, "e1", "newer way", task="ship service",
)
assert out["success"] is True
stored = await working.get_by_id("e1")
assert stored.task == "ship service"
hits = await working.search_by_query_embedding(
await mock_embed.embed("ship service"), user_id="u",
)
assert hits[0]["experience_id"] == "e1"
assert hits[0]["score"] == pytest.approx(1.0, abs=1e-4)
finally:
await working.close()
async def test_update_and_delete_fact(coding_config, mock_embed):
coding = await _store(coding_config, mock_embed, "coding")
try:
await coding.insert_fact(WorkingFact(
fact_id="f1", user_id="u", statement="build uses make",
))
client = _client(coding_config, mock_embed, {"coding": coding})
out = await _unwrapped("async_update")(
client, "f1", "build uses bazel", kind="fact",
)
assert out["success"] is True and out["variant"] == "coding"
assert (await coding.get_fact("f1")).statement == "build uses bazel"
hits = await coding.search_facts(
await mock_embed.embed("build uses bazel"), user_id="u",
)
assert [f.fact_id for f, _ in hits] == ["f1"]
with pytest.raises(ValueError, match="task"):
await _unwrapped("async_update")(client, "f1", "x", task="t")
out = await _unwrapped("async_delete")(client, "f1")
assert out["success"] is True and out["deleted_count"] == 1
assert out["kind"] == "fact"
assert await coding.get_fact("f1") is None
assert await coding.search_facts(
await mock_embed.embed("build uses bazel"), user_id="u",
) == []
finally:
await coding.close()
async def test_delete_experience_removes_row_and_vectors(coding_config, mock_embed):
working = await _store(coding_config, mock_embed, "working")
try:
await working.insert(CodingMemory(
experience_id="e1", user_id="u", task="deploy service",
search_keys=["rollout"], solution="S",
))
client = _client(coding_config, mock_embed, {"working": working})
out = await _unwrapped("async_delete")(
client, "e1", kind="experience", variant="working",
)
assert out["success"] is True
assert out["deleted_count"] == 1
assert out["memory_id"] == "e1"
assert await working.get_by_id("e1") is None
assert await working._vec.get_by_id("e1:0") is None
assert await working._vec.get_by_id("e1:1") is None
finally:
await working.close()
async def test_productivity_not_found_and_ambiguity(coding_config, mock_embed):
working = await _store(coding_config, mock_embed, "working")
coding = await _store(coding_config, mock_embed, "coding")
try:
for store in (working, coding):
await store.insert(CodingMemory(
experience_id="same", user_id="u", task="T", solution="S",
))
client = _client(coding_config, mock_embed,
{"working": working, "coding": coding})
with pytest.raises(AmbiguousMemoryIdError):
await _unwrapped("async_update")(client, "same", "x")
with pytest.raises(AmbiguousMemoryIdError):
await _unwrapped("async_delete")(client, "same")
out = await _unwrapped("async_delete")(client, "same", variant="coding")
assert out["success"] is True and out["variant"] == "coding"
assert await working.get_by_id("same") is not None
missing = await _unwrapped("async_update")(client, "nope", "x", kind="experience")
assert missing["success"] is False and "not found" in missing["error_message"]
missing = await _unwrapped("async_delete")(client, "nope", kind="fact")
assert missing["success"] is False and missing["deleted_count"] == 0
# variant 给了就不回落到 chat
missing = await _unwrapped("async_delete")(client, "nope", variant="working")
assert missing["success"] is False and missing["deleted_count"] == 0
with pytest.raises(ValueError):
await _unwrapped("async_delete")(client, "same", kind="chat", variant="coding")
finally:
await working.close()
await coding.close()
async def test_update_and_delete_without_kind_still_handle_chat(coding_config, mock_embed):
"""默认行为不变:chat ID 走原 chat 路径(store 都没建时也不建库)。"""
client = _client(coding_config, mock_embed, {})
seen = []
async def _chat_update(memory_id, content):
seen.append(("update", memory_id, content))
return {"success": True, "memory_id": memory_id}
async def _chat_delete(memory_id):
seen.append(("delete", memory_id))
return {"success": True, "deleted_count": 1}
client._update_chat = _chat_update
client._delete_chat = _chat_delete
client._vector_store = _FakeVectorStore([_chat_node("m1", created=BASE)])
assert (await _unwrapped("async_update")(client, "m1", "new"))["success"]
assert (await _unwrapped("async_delete")(client, "m1"))["success"]
# 哪都没有:仍交给 chat 路径给出原来的 not-found 结果
await _unwrapped("async_delete")(client, "ghost")
assert seen == [("update", "m1", "new"), ("delete", "m1"), ("delete", "ghost")]
for variant in ("working", "coding"):
assert not CodingMemoryStore.database_exists(
coding_config, db_path=coding_config.coding.db_path, variant=variant,
)
+4 -3
View File
@@ -6,7 +6,7 @@ working 两类经验既列不出也数不到:一个 45 条记忆的库在界
"""
import asyncio
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
@@ -97,13 +97,14 @@ async def test_stats_counts_every_scene_with_its_latest_times(coding_config, moc
"latest_updated_at": int((BASE + timedelta(days=1)).timestamp()),
}
assert stats["scenes"]["coding"]["count"] == 2
# Productivity stores write naive UTC (utcnow), so naive means UTC.
assert stats["scenes"]["coding"]["latest_created_at"] == int(
(BASE + timedelta(hours=2)).timestamp()
(BASE + timedelta(hours=2)).replace(tzinfo=timezone.utc).timestamp()
)
# The store stamps updated_at on write; the stat is the newest it holds.
stored = [await coding.get_by_id(eid, user_id="u") for eid in ("c1", "c2")]
assert stats["scenes"]["coding"]["latest_updated_at"] == max(
int(item.updated_at.timestamp()) for item in stored
int(item.updated_at.replace(tzinfo=timezone.utc).timestamp()) for item in stored
)
assert stats["scenes"]["working"]["count"] == 1
assert stats["scenes"]["working"]["facts"] == 1
+309
View File
@@ -0,0 +1,309 @@
# -*- coding: utf-8 -*-
"""search(rerank=...) 与 MMR 的按次开关。
- rerank=False:本地 cross-encoder 与图重排都不跑(看 spy 是否被调到)。
- MMR:调用方的 limit < MMR_MAX_TOP_K 时启用,>= 时跳过。
- 默认值(rerank=True、limit=10)保持原行为。
"""
import asyncio
import math
import uuid
import pytest
from hy_memory.config import MemoryConfig
from hy_memory.models.memory import MemoryLayer, MemoryNode, MemoryStatus
from hy_memory.pipelines import reader_base
from hy_memory.pipelines._retrieval.diversity import (
MMR_MAX_TOP_K,
mmr_applies,
near_dup_applies,
)
from hy_memory.pipelines.base import ReadRequest
DIMS = 32
def _unit(vec):
n = sum(x * x for x in vec) ** 0.5 or 1.0
return [x / n for x in vec]
def _query_vec():
return _unit([1.0] + [0.0] * (DIMS - 1))
def _vec(cos):
return _unit([cos, math.sqrt(1 - cos * cos)] + [0.0] * (DIMS - 2))
@pytest.fixture
def config(tmp_path):
return MemoryConfig.from_dict({"vector_store": {
"provider": "chroma",
"collection_name": f"t_gates_{uuid.uuid4().hex[:8]}",
"persist_directory": str(tmp_path / "chroma"),
"embedding_dims": DIMS,
}})
async def _reader_with(config, count):
from hy_memory.data.vector_store_chroma import ChromaVectorStore
from hy_memory.pipelines.reader_legacy import LegacyReadPipeline
store = ChromaVectorStore(config)
await store.initialize()
for index in range(count):
node = MemoryNode(
node_id=f"m{index:02d}", user_id="u1", agent_id="a1", session_id="s1",
content=f"memory number {index} about topic{index} and detail{index}",
layer=MemoryLayer.L2_FACT, status=MemoryStatus.ACTIVE, is_latest=True,
)
node.embedding = _vec(0.95 - index * 0.01)
await store.upsert(node)
reader = LegacyReadPipeline(config, vector_store=store)
await reader.initialize()
return reader, store
def _request(limit, **kw):
return ReadRequest(
query="memory topic", query_embedding=_query_vec(),
user_ids=["u1"], agent_ids=["a1"],
limit=limit, min_score=0.2, schema_min_score=0.2, **kw,
)
@pytest.fixture
def mmr_spy(monkeypatch):
calls = []
real = reader_base.mmr_select
def _spy(hits, limit, **kw):
calls.append(limit)
return real(hits, limit, **kw)
monkeypatch.setattr(reader_base, "mmr_select", _spy)
return calls
def test_mmr_threshold_is_defined_once():
assert MMR_MAX_TOP_K == 20
assert mmr_applies(19, 0.55)
assert not mmr_applies(20, 0.55)
# 权重仍由 MMR_LAMBDA 控制:关掉 λ 时任何 top-k 都不走 MMR
assert not mmr_applies(5, 0.0)
assert not mmr_applies(5, 1.0)
def test_mmr_runs_when_limit_below_threshold(config, mmr_spy):
async def _run():
reader, store = await _reader_with(config, 12)
try:
resp = await reader.read(_request(5))
assert resp.success
assert resp.extra["config"]["mmr_applied"] is True
assert len(resp.memories) == 5
finally:
await store.close()
asyncio.run(_run())
assert mmr_spy == [5]
def test_mmr_skipped_when_limit_reaches_threshold(config, mmr_spy):
async def _run():
reader, store = await _reader_with(config, MMR_MAX_TOP_K + 6)
try:
resp = await reader.read(_request(MMR_MAX_TOP_K))
assert resp.success
assert resp.extra["config"]["mmr_applied"] is False
ids = [m["memory_id"] for m in resp.memories]
assert len(ids) == MMR_MAX_TOP_K
# 无 MMR:按召回分(构造时单调递减)原序截断
assert ids == [f"m{index:02d}" for index in range(MMR_MAX_TOP_K)]
finally:
await store.close()
asyncio.run(_run())
assert mmr_spy == []
@pytest.fixture
def near_dup_spy(monkeypatch):
calls = []
real = reader_base.near_dup_demote
def _spy(hits, **kw):
calls.append(len(hits))
return real(hits, **kw)
monkeypatch.setattr(reader_base, "near_dup_demote", _spy)
return calls
def test_near_dup_shares_the_mmr_threshold():
# Pairwise Jaccard against every kept candidate: quadratic in the pool,
# which grows with limit. Same gate as MMR.
assert near_dup_applies(MMR_MAX_TOP_K - 1)
assert not near_dup_applies(MMR_MAX_TOP_K)
def test_near_dup_runs_when_limit_below_threshold(config, near_dup_spy):
async def _run():
reader, store = await _reader_with(config, 12)
try:
resp = await reader.read(_request(5))
assert resp.extra["config"]["near_dup_applied"] is True
finally:
await store.close()
asyncio.run(_run())
assert len(near_dup_spy) == 1
def test_near_dup_skipped_when_limit_reaches_threshold(config, near_dup_spy):
async def _run():
reader, store = await _reader_with(config, MMR_MAX_TOP_K + 6)
try:
resp = await reader.read(_request(MMR_MAX_TOP_K))
assert resp.success
assert resp.extra["config"]["near_dup_applied"] is False
finally:
await store.close()
asyncio.run(_run())
assert near_dup_spy == []
class _FakeReranker:
model_name = "fake-cross-encoder"
device = "cpu"
load_error = ""
def __init__(self):
self.calls = 0
def score(self, query, documents):
self.calls += 1
# 反转召回序:分数与原名次相反
return [float(i) for i in range(len(documents))]
@pytest.fixture
def rerank_spies(config, monkeypatch):
config.recall.local_rerank_enabled = True
reranker = _FakeReranker()
factory_calls = []
def _get_local_reranker(*args, **kwargs):
factory_calls.append(kwargs)
return reranker
monkeypatch.setattr(reader_base, "get_local_reranker", _get_local_reranker)
graph_calls = []
async def _graph_rerank(self, *, hits, **kwargs):
graph_calls.append(len(hits))
return hits, [], {}
monkeypatch.setattr(
reader_base.BaseReadPipeline, "_apply_graph_rerank", _graph_rerank,
)
monkeypatch.setattr(
reader_base.BaseReadPipeline, "_expansion_on", lambda self: True,
)
return {"reranker": reranker, "factory": factory_calls, "graph": graph_calls}
def test_rerank_false_skips_local_and_graph_rerank(config, rerank_spies):
async def _run():
reader, store = await _reader_with(config, 8)
try:
resp = await reader.read(_request(5, rerank=False))
assert resp.success
assert resp.extra["config"]["rerank"] is False
assert resp.extra["config"]["local_rerank_enabled"] is False
assert resp.extra["local_rerank"]["applied"] is False
return [m["memory_id"] for m in resp.memories]
finally:
await store.close()
ids = asyncio.run(_run())
assert rerank_spies["factory"] == []
assert rerank_spies["reranker"].calls == 0
assert rerank_spies["graph"] == []
assert len(ids) == 5
def test_rerank_default_true_keeps_rerank_stages(config, rerank_spies):
async def _run():
reader, store = await _reader_with(config, 8)
try:
req = _request(5)
assert req.rerank is True
resp = await reader.read(req)
assert resp.success
assert resp.extra["config"]["rerank"] is True
finally:
await store.close()
asyncio.run(_run())
assert len(rerank_spies["factory"]) == 1
assert rerank_spies["reranker"].calls >= 1
assert len(rerank_spies["graph"]) == 1
def test_rerank_is_per_request_under_concurrency(config, rerank_spies):
"""并发的两次 read 各按自己的 rerank 走,不互相影响。"""
async def _run():
reader, store = await _reader_with(config, 8)
try:
off, on = await asyncio.gather(
reader.read(_request(5, rerank=False)),
reader.read(_request(5, rerank=True)),
)
assert off.extra["config"]["rerank"] is False
assert on.extra["config"]["rerank"] is True
assert off.extra["local_rerank"]["applied"] is False
finally:
await store.close()
asyncio.run(_run())
assert len(rerank_spies["factory"]) == 1
assert len(rerank_spies["graph"]) == 1
@pytest.mark.parametrize("kwargs, expected", [({}, True), ({"rerank": False}, False)])
def test_client_search_threads_rerank_into_read_request(kwargs, expected):
"""client.async_search → ReadRequest.rerank;默认 True(原行为)。"""
from hy_memory.client import HyMemoryClient
from hy_memory.pipelines.base import ReadResponse
seen = {}
class _Reader:
VERSION = "fake"
async def read(self, request):
seen["rerank"] = request.rerank
seen["limit"] = request.limit
return ReadResponse()
class _Registry:
async def get_reader(self, reader_name=""):
return _Reader()
client = object.__new__(HyMemoryClient)
client._config = MemoryConfig.from_env()
client._cache = None
client._history_store = None
client._registry = _Registry()
client._mode = "pro"
out = asyncio.run(HyMemoryClient.async_search.__wrapped__(
client, "q", user_ids=["u"], limit=7, **kwargs,
))
assert "memories" in out
assert seen == {"rerank": expected, "limit": 7}
+103
View File
@@ -0,0 +1,103 @@
"""The memory routes report an ambiguous id as 409 and pass kind/variant on.
An id can exist in more than one namespace (chat, experience, fact). The
client fails closed with AmbiguousMemoryIdError; over HTTP that was a 500.
"""
import json
import threading
from http.server import ThreadingHTTPServer
from urllib.request import Request, urlopen
from urllib.error import HTTPError
import pytest
from hy_memory import server
from hy_memory.retrieval import AmbiguousMemoryIdError
class _Client:
def __init__(self):
self.calls = []
def get(self, memory_id, **kwargs):
self.calls.append(("get", memory_id, kwargs))
if not kwargs:
raise AmbiguousMemoryIdError(memory_id, ("chat", "working/experience"))
return {"memory_id": memory_id}
def update(self, memory_id, content, **kwargs):
self.calls.append(("update", memory_id, kwargs))
return {"success": True, "memory_id": memory_id}
def delete(self, memory_id, **kwargs):
self.calls.append(("delete", memory_id, kwargs))
if kwargs.get("kind") == "bogus":
raise ValueError("kind must be one of: chat, experience, fact")
return {"success": True, "deleted_count": 1}
@pytest.fixture
def running(monkeypatch):
client = _Client()
monkeypatch.setattr(server, "_get_client", lambda: client)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.MemoryHTTPHandler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
yield client, f"http://127.0.0.1:{httpd.server_address[1]}"
finally:
httpd.shutdown()
httpd.server_close()
def _call(method, url, body=None):
data = json.dumps(body).encode() if body is not None else None
request = Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
try:
with urlopen(request) as response:
return response.status, json.loads(response.read())
except HTTPError as error:
return error.code, json.loads(error.read())
def test_an_ambiguous_id_is_409_not_500(running):
_, base = running
status, body = _call("GET", f"{base}/api/v1/memories/m1")
assert status == 409
assert body["error"] == "ambiguous_memory_id"
def test_kind_and_variant_disambiguate_get_update_and_delete(running):
client, base = running
assert _call("GET", f"{base}/api/v1/memories/m1?kind=experience&variant=coding")[0] == 200
assert _call(
"PUT", f"{base}/api/v1/memories/m1",
{"content": "new", "kind": "experience", "variant": "working", "task": "T"},
)[0] == 200
assert _call("DELETE", f"{base}/api/v1/memories/m1?kind=fact&variant=coding")[0] == 200
assert client.calls == [
("get", "m1", {"kind": "experience", "variant": "coding"}),
("update", "m1", {"kind": "experience", "variant": "working", "task": "T"}),
("delete", "m1", {"kind": "fact", "variant": "coding"}),
]
def test_a_bad_namespace_is_400(running):
_, base = running
status, _ = _call("DELETE", f"{base}/api/v1/memories/m1?kind=bogus")
assert status == 400
def test_chat_calls_without_a_namespace_are_unchanged(running):
client, base = running
_call("PUT", f"{base}/api/v1/memories/m1", {"content": "new"})
assert client.calls == [("update", "m1", {})]