Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dac2c15b3f | ||
|
|
0b775d7d61 | ||
|
|
4e217f845e | ||
|
|
2102a298d4 | ||
|
|
e540386db1 | ||
|
|
1374ac26b7 | ||
|
|
47dafbb102 | ||
|
|
49ad1b3086 |
No files matched your search
+54
-13
@@ -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
File diff suppressed because it is too large.
Load diff
+132
-10
@@ -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,
|
||||
|
||||
@@ -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 >= 此值的记忆
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`。
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
Generated
+4
-4
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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), {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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", {})]
|
||||
Reference in new issue
Block a user